mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 19:23:34 +00:00
Merge remote-tracking branch 'upstream/master' into feat/smartcam_passthrough
This commit is contained in:
commit
cddac30479
@ -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/)
|
* [Home Assistant](https://www.home-assistant.io/integrations/tplink/)
|
||||||
* [MQTT access to TP-Link devices, using python-kasa](https://github.com/flavio-fernandes/mqtt2kasa)
|
* [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
|
### Other related projects
|
||||||
|
|
||||||
|
13
docs/source/featureattributes.md
Normal file
13
docs/source/featureattributes.md
Normal file
@ -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.
|
@ -13,11 +13,13 @@
|
|||||||
|
|
||||||
## Device
|
## Device
|
||||||
|
|
||||||
|
% N.B. Credentials clashes with autodoc
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoclass:: Device
|
.. autoclass:: Device
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
|
:exclude-members: Credentials
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
@ -28,7 +30,6 @@
|
|||||||
.. autoclass:: Credentials
|
.. autoclass:: Credentials
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
:noindex:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
@ -61,15 +62,11 @@
|
|||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoclass:: Module
|
.. autoclass:: Module
|
||||||
:noindex:
|
|
||||||
:members:
|
:members:
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoclass:: Feature
|
.. autoclass:: Feature
|
||||||
:noindex:
|
|
||||||
:members:
|
:members:
|
||||||
:inherited-members:
|
:inherited-members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
@ -77,7 +74,6 @@
|
|||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. automodule:: kasa.interfaces
|
.. automodule:: kasa.interfaces
|
||||||
:noindex:
|
|
||||||
:members:
|
:members:
|
||||||
:inherited-members:
|
:inherited-members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
@ -85,64 +81,29 @@
|
|||||||
|
|
||||||
## Protocols and transports
|
## Protocols and transports
|
||||||
|
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoclass:: kasa.protocols.BaseProtocol
|
.. automodule:: kasa.protocols
|
||||||
:members:
|
:members:
|
||||||
:inherited-members:
|
:imported-members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
|
:exclude-members: SmartErrorCode
|
||||||
|
:no-index:
|
||||||
```
|
```
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoclass:: kasa.protocols.IotProtocol
|
.. automodule:: kasa.transports
|
||||||
:members:
|
:members:
|
||||||
:inherited-members:
|
:imported-members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
|
:no-index:
|
||||||
```
|
```
|
||||||
|
|
||||||
```{eval-rst}
|
|
||||||
.. autoclass:: kasa.protocols.SmartProtocol
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
```
|
|
||||||
|
|
||||||
```{eval-rst}
|
|
||||||
.. autoclass:: kasa.transports.BaseTransport
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
```
|
|
||||||
|
|
||||||
```{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:
|
|
||||||
```
|
|
||||||
|
|
||||||
```{eval-rst}
|
|
||||||
.. autoclass:: kasa.transports.AesTransport
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
```
|
|
||||||
|
|
||||||
## Errors and exceptions
|
## Errors and exceptions
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```{eval-rst}
|
```{eval-rst}
|
||||||
.. autoclass:: kasa.exceptions.KasaException
|
.. autoclass:: kasa.exceptions.KasaException
|
||||||
:members:
|
:members:
|
||||||
@ -171,3 +132,4 @@
|
|||||||
.. autoclass:: kasa.exceptions.TimeoutError
|
.. autoclass:: kasa.exceptions.TimeoutError
|
||||||
:members:
|
:members:
|
||||||
:undoc-members:
|
:undoc-members:
|
||||||
|
```
|
||||||
|
@ -80,14 +80,17 @@ This can be done using the {attr}`~kasa.Device.internal_state` property.
|
|||||||
## Modules and Features
|
## Modules and Features
|
||||||
|
|
||||||
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,
|
While the device class provides easy access for most device related attributes,
|
||||||
you can also access individual modules through {attr}`kasa.Device.modules`.
|
for components like `light` and `camera` you can access the module through {attr}`kasa.Device.modules`.
|
||||||
You can get the list of supported modules for a given device instance using {attr}`~kasa.Device.supported_modules`.
|
The module names are handily available as constants on {class}`~kasa.Module` and will return type aware values from the collection.
|
||||||
|
|
||||||
```{note}
|
Features represent individual pieces of functionality within a module like brightness, hsv and temperature within a light module.
|
||||||
If you only need some module-specific information,
|
They allow for instrospection and can be accessed through {attr}`kasa.Device.features`.
|
||||||
you can call the wanted method on the module to avoid using {meth}`~kasa.Device.update`.
|
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)=
|
(topics-protocols-and-transports)=
|
||||||
## Protocols and Transports
|
## Protocols and Transports
|
||||||
@ -137,96 +140,3 @@ The base exception for all library errors is {class}`KasaException <kasa.excepti
|
|||||||
- If the library encounters and unsupported deviceit raises an {class}`UnsupportedDeviceError <kasa.exceptions.UnsupportedDeviceError>`.
|
- If the library encounters and unsupported deviceit raises an {class}`UnsupportedDeviceError <kasa.exceptions.UnsupportedDeviceError>`.
|
||||||
- If the device fails to respond within a timeout the library raises a {class}`TimeoutError <kasa.exceptions.TimeoutError>`.
|
- If the device fails to respond within a timeout the library raises a {class}`TimeoutError <kasa.exceptions.TimeoutError>`.
|
||||||
- All other failures will raise the base {class}`KasaException <kasa.exceptions.KasaException>` class.
|
- All other failures will raise the base {class}`KasaException <kasa.exceptions.KasaException>` class.
|
||||||
|
|
||||||
<!-- Commenting out this section keeps git seeing the change as a rename.
|
|
||||||
|
|
||||||
API documentation for modules and features
|
|
||||||
******************************************
|
|
||||||
|
|
||||||
.. 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:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
API documentation for protocols and transports
|
|
||||||
**********************************************
|
|
||||||
|
|
||||||
.. autoclass:: kasa.protocols.BaseProtocol
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
.. autoclass:: kasa.protocols.IotProtocol
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
.. autoclass:: kasa.protocols.SmartProtocol
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
.. autoclass:: kasa.transports.BaseTransport
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
.. autoclass:: kasa.transports.XorTransport
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
.. autoclass:: kasa.transports.KlapTransport
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
.. autoclass:: kasa.transports.KlapTransportV2
|
|
||||||
:members:
|
|
||||||
:inherited-members:
|
|
||||||
:undoc-members:
|
|
||||||
|
|
||||||
.. autoclass:: kasa.transports.AesTransport
|
|
||||||
: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:
|
|
||||||
|
|
||||||
-->
|
|
||||||
|
@ -40,7 +40,7 @@ Different groups of functionality are supported by modules which you can access
|
|||||||
key from :class:`~kasa.Module`.
|
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.
|
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
|
>>> from kasa import Module
|
||||||
>>> Module.Light in dev.modules
|
>>> Module.Light in dev.modules
|
||||||
@ -52,9 +52,9 @@ True
|
|||||||
>>> await dev.update()
|
>>> await dev.update()
|
||||||
>>> light.brightness
|
>>> light.brightness
|
||||||
50
|
50
|
||||||
>>> light.is_color
|
>>> light.has_feature("hsv")
|
||||||
True
|
True
|
||||||
>>> if light.is_color:
|
>>> if light.has_feature("hsv"):
|
||||||
>>> print(light.hsv)
|
>>> print(light.hsv)
|
||||||
HSV(hue=0, saturation=100, value=50)
|
HSV(hue=0, saturation=100, value=50)
|
||||||
|
|
||||||
|
@ -41,8 +41,14 @@ async def state(ctx, dev: Device):
|
|||||||
echo(f"Device state: {dev.is_on}")
|
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(
|
||||||
echo(f"Software: {dev.hw_info['sw_ver']}")
|
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})")
|
echo(f"MAC (rssi): {dev.mac} ({dev.rssi})")
|
||||||
if verbose:
|
if verbose:
|
||||||
echo(f"Location: {dev.location}")
|
echo(f"Location: {dev.location}")
|
||||||
|
@ -25,7 +25,9 @@ def light(dev) -> None:
|
|||||||
@pass_dev_or_child
|
@pass_dev_or_child
|
||||||
async def brightness(dev: Device, brightness: int, transition: int):
|
async def brightness(dev: Device, brightness: int, transition: int):
|
||||||
"""Get or set brightness."""
|
"""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.")
|
error("This device does not support brightness.")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -45,13 +47,15 @@ async def brightness(dev: Device, brightness: int, transition: int):
|
|||||||
@pass_dev_or_child
|
@pass_dev_or_child
|
||||||
async def temperature(dev: Device, temperature: int, transition: int):
|
async def temperature(dev: Device, temperature: int, transition: int):
|
||||||
"""Get or set color temperature."""
|
"""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")
|
error("Device does not support color temperature")
|
||||||
return
|
return
|
||||||
|
|
||||||
if temperature is None:
|
if temperature is None:
|
||||||
echo(f"Color temperature: {light.color_temp}")
|
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):
|
if valid_temperature_range != (0, 0):
|
||||||
echo("(min: {}, max: {})".format(*valid_temperature_range))
|
echo("(min: {}, max: {})".format(*valid_temperature_range))
|
||||||
else:
|
else:
|
||||||
@ -59,7 +63,7 @@ async def temperature(dev: Device, temperature: int, transition: int):
|
|||||||
"Temperature range unknown, please open a github issue"
|
"Temperature range unknown, please open a github issue"
|
||||||
f" or a pull request for model '{dev.model}'"
|
f" or a pull request for model '{dev.model}'"
|
||||||
)
|
)
|
||||||
return light.valid_temperature_range
|
return color_temp_feat.range
|
||||||
else:
|
else:
|
||||||
echo(f"Setting color temperature to {temperature}")
|
echo(f"Setting color temperature to {temperature}")
|
||||||
return await light.set_color_temp(temperature, transition=transition)
|
return await light.set_color_temp(temperature, transition=transition)
|
||||||
@ -99,7 +103,7 @@ async def effect(dev: Device, ctx, effect):
|
|||||||
@pass_dev_or_child
|
@pass_dev_or_child
|
||||||
async def hsv(dev: Device, ctx, h, s, v, transition):
|
async def hsv(dev: Device, ctx, h, s, v, transition):
|
||||||
"""Get or set color in HSV."""
|
"""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")
|
error("Device does not support colors")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -29,7 +29,7 @@ All devices provide several informational properties:
|
|||||||
>>> dev.alias
|
>>> dev.alias
|
||||||
Bedroom Lamp Plug
|
Bedroom Lamp Plug
|
||||||
>>> dev.model
|
>>> dev.model
|
||||||
HS110(EU)
|
HS110
|
||||||
>>> dev.rssi
|
>>> dev.rssi
|
||||||
-71
|
-71
|
||||||
>>> dev.mac
|
>>> dev.mac
|
||||||
@ -151,7 +151,7 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class _DeviceInfo:
|
class DeviceInfo:
|
||||||
"""Device Model Information."""
|
"""Device Model Information."""
|
||||||
|
|
||||||
short_name: str
|
short_name: str
|
||||||
@ -208,7 +208,7 @@ class Device(ABC):
|
|||||||
self.protocol: BaseProtocol = protocol or IotProtocol(
|
self.protocol: BaseProtocol = protocol or IotProtocol(
|
||||||
transport=XorTransport(config=config or DeviceConfig(host=host)),
|
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))
|
_LOGGER.debug("Initializing %s of type %s", host, type(self))
|
||||||
self._device_type = DeviceType.Unknown
|
self._device_type = DeviceType.Unknown
|
||||||
# TODO: typing Any is just as using dict | None would require separate
|
# TODO: typing Any is just as using dict | None would require separate
|
||||||
@ -334,9 +334,21 @@ class Device(ABC):
|
|||||||
"""Returns the device model."""
|
"""Returns the device model."""
|
||||||
|
|
||||||
@property
|
@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
|
@abstractmethod
|
||||||
def _model_region(self) -> str:
|
def _get_device_info(
|
||||||
"""Return device full model name and region."""
|
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
||||||
|
) -> DeviceInfo:
|
||||||
|
"""Get device info."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
@ -22,7 +22,7 @@ Discovery returns a dict of {ip: discovered devices}:
|
|||||||
>>>
|
>>>
|
||||||
>>> found_devices = await Discover.discover()
|
>>> found_devices = await Discover.discover()
|
||||||
>>> [dev.model for dev in found_devices.values()]
|
>>> [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
|
You can pass username and password for devices requiring authentication
|
||||||
|
|
||||||
@ -65,17 +65,17 @@ It is also possible to pass a coroutine to be executed for each found device:
|
|||||||
>>> print(f"Discovered {dev.alias} (model: {dev.model})")
|
>>> print(f"Discovered {dev.alias} (model: {dev.model})")
|
||||||
>>>
|
>>>
|
||||||
>>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds)
|
>>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds)
|
||||||
Discovered Bedroom Power Strip (model: KP303(UK))
|
Discovered Bedroom Power Strip (model: KP303)
|
||||||
Discovered Bedroom Lamp Plug (model: HS110(EU))
|
Discovered Bedroom Lamp Plug (model: HS110)
|
||||||
Discovered Living Room Bulb (model: L530)
|
Discovered Living Room Bulb (model: L530)
|
||||||
Discovered Bedroom Lightstrip (model: KL430(US))
|
Discovered Bedroom Lightstrip (model: KL430)
|
||||||
Discovered Living Room Dimmer Switch (model: HS220(US))
|
Discovered Living Room Dimmer Switch (model: HS220)
|
||||||
|
|
||||||
Discovering a single device returns a kasa.Device object.
|
Discovering a single device returns a kasa.Device object.
|
||||||
|
|
||||||
>>> device = await Discover.discover_single("127.0.0.1", credentials=creds)
|
>>> device = await Discover.discover_single("127.0.0.1", credentials=creds)
|
||||||
>>> device.model
|
>>> device.model
|
||||||
'KP303(UK)'
|
'KP303'
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -23,13 +23,13 @@ Get the light module to interact:
|
|||||||
|
|
||||||
>>> light = dev.modules[Module.Light]
|
>>> 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
|
True
|
||||||
>>> light.is_color
|
>>> light.has_feature("hsv")
|
||||||
True
|
True
|
||||||
>>> light.is_variable_color_temp
|
>>> light.has_feature("color_temp")
|
||||||
True
|
True
|
||||||
|
|
||||||
All known bulbs support changing the brightness:
|
All known bulbs support changing the brightness:
|
||||||
@ -43,8 +43,9 @@ All known bulbs support changing the brightness:
|
|||||||
|
|
||||||
Bulbs supporting color temperature can be queried for the supported range:
|
Bulbs supporting color temperature can be queried for the supported range:
|
||||||
|
|
||||||
>>> light.valid_temperature_range
|
>>> if color_temp_feature := light.get_feature("color_temp"):
|
||||||
ColorTempRange(min=2500, max=6500)
|
>>> print(f"{color_temp_feature.minimum_value}, {color_temp_feature.maximum_value}")
|
||||||
|
2500, 6500
|
||||||
>>> await light.set_color_temp(3000)
|
>>> await light.set_color_temp(3000)
|
||||||
>>> await dev.update()
|
>>> await dev.update()
|
||||||
>>> light.color_temp
|
>>> light.color_temp
|
||||||
|
@ -13,8 +13,7 @@ Living Room Bulb
|
|||||||
|
|
||||||
Light effects are accessed via the LightPreset module. To list available presets
|
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
|
>>> light_effect.effect_list
|
||||||
['Off', 'Party', 'Relax']
|
['Off', 'Party', 'Relax']
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ from datetime import datetime, timedelta, tzinfo
|
|||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
||||||
from ..device import Device, WifiNetwork, _DeviceInfo
|
from ..device import Device, DeviceInfo, WifiNetwork
|
||||||
from ..device_type import DeviceType
|
from ..device_type import DeviceType
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
from ..exceptions import KasaException
|
from ..exceptions import KasaException
|
||||||
@ -43,7 +43,7 @@ def requires_update(f: Callable) -> Any:
|
|||||||
@functools.wraps(f)
|
@functools.wraps(f)
|
||||||
async def wrapped(*args: Any, **kwargs: Any) -> Any:
|
async def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||||
self = args[0]
|
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
|
self._sys_info is None or f.__name__ not in self._sys_info
|
||||||
):
|
):
|
||||||
raise KasaException("You need to await update() to access the data")
|
raise KasaException("You need to await update() to access the data")
|
||||||
@ -54,7 +54,7 @@ def requires_update(f: Callable) -> Any:
|
|||||||
@functools.wraps(f)
|
@functools.wraps(f)
|
||||||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||||
self = args[0]
|
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
|
self._sys_info is None or f.__name__ not in self._sys_info
|
||||||
):
|
):
|
||||||
raise KasaException("You need to await update() to access the data")
|
raise KasaException("You need to await update() to access the data")
|
||||||
@ -112,7 +112,7 @@ class IotDevice(Device):
|
|||||||
>>> dev.alias
|
>>> dev.alias
|
||||||
Bedroom Lamp Plug
|
Bedroom Lamp Plug
|
||||||
>>> dev.model
|
>>> dev.model
|
||||||
HS110(EU)
|
HS110
|
||||||
>>> dev.rssi
|
>>> dev.rssi
|
||||||
-71
|
-71
|
||||||
>>> dev.mac
|
>>> dev.mac
|
||||||
@ -310,7 +310,7 @@ class IotDevice(Device):
|
|||||||
# If this is the initial update, check only for the sysinfo
|
# If this is the initial update, check only for the sysinfo
|
||||||
# This is necessary as some devices crash on unexpected modules
|
# This is necessary as some devices crash on unexpected modules
|
||||||
# See #105, #120, #161
|
# See #105, #120, #161
|
||||||
if self._last_update is None:
|
if not self._last_update:
|
||||||
_LOGGER.debug("Performing the initial update to obtain sysinfo")
|
_LOGGER.debug("Performing the initial update to obtain sysinfo")
|
||||||
response = await self.protocol.query(req)
|
response = await self.protocol.query(req)
|
||||||
self._last_update = response
|
self._last_update = response
|
||||||
@ -452,7 +452,9 @@ class IotDevice(Device):
|
|||||||
# This allows setting of some info properties directly
|
# This allows setting of some info properties directly
|
||||||
# from partial discovery info that will then be found
|
# from partial discovery info that will then be found
|
||||||
# by the requires_update decorator
|
# 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:
|
def _set_sys_info(self, sys_info: dict[str, Any]) -> None:
|
||||||
"""Set sys_info."""
|
"""Set sys_info."""
|
||||||
@ -471,18 +473,13 @@ class IotDevice(Device):
|
|||||||
"""
|
"""
|
||||||
return self._sys_info # type: ignore
|
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
|
@property
|
||||||
@requires_update
|
@requires_update
|
||||||
def _model_region(self) -> str:
|
def model(self) -> str:
|
||||||
"""Return device full model name and region."""
|
"""Returns the device model."""
|
||||||
return self.model
|
if self._last_update:
|
||||||
|
return self.device_info.short_name
|
||||||
|
return self._sys_info["model"]
|
||||||
|
|
||||||
@property # type: ignore
|
@property # type: ignore
|
||||||
def alias(self) -> str | None:
|
def alias(self) -> str | None:
|
||||||
@ -748,7 +745,7 @@ class IotDevice(Device):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_device_info(
|
def _get_device_info(
|
||||||
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
||||||
) -> _DeviceInfo:
|
) -> DeviceInfo:
|
||||||
"""Get model information for a device."""
|
"""Get model information for a device."""
|
||||||
sys_info = _extract_sys_info(info)
|
sys_info = _extract_sys_info(info)
|
||||||
|
|
||||||
@ -766,7 +763,7 @@ class IotDevice(Device):
|
|||||||
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
|
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
|
||||||
auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info))
|
auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info))
|
||||||
|
|
||||||
return _DeviceInfo(
|
return DeviceInfo(
|
||||||
short_name=long_name,
|
short_name=long_name,
|
||||||
long_name=long_name,
|
long_name=long_name,
|
||||||
brand="kasa",
|
brand="kasa",
|
||||||
|
@ -3,13 +3,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, Annotated, cast
|
||||||
|
|
||||||
from ...device_type import DeviceType
|
from ...device_type import DeviceType
|
||||||
from ...exceptions import KasaException
|
from ...exceptions import KasaException
|
||||||
from ...feature import Feature
|
from ...feature import Feature
|
||||||
from ...interfaces.light import HSV, ColorTempRange, LightState
|
from ...interfaces.light import HSV, ColorTempRange, LightState
|
||||||
from ...interfaces.light import Light as LightInterface
|
from ...interfaces.light import Light as LightInterface
|
||||||
|
from ...module import FeatureAttribute
|
||||||
from ..iotmodule import IotModule
|
from ..iotmodule import IotModule
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -32,7 +33,7 @@ class Light(IotModule, LightInterface):
|
|||||||
super()._initialize_features()
|
super()._initialize_features()
|
||||||
device = self._device
|
device = self._device
|
||||||
|
|
||||||
if self._device._is_dimmable:
|
if device._is_dimmable:
|
||||||
self._add_feature(
|
self._add_feature(
|
||||||
Feature(
|
Feature(
|
||||||
device,
|
device,
|
||||||
@ -46,7 +47,7 @@ class Light(IotModule, LightInterface):
|
|||||||
category=Feature.Category.Primary,
|
category=Feature.Category.Primary,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self._device._is_variable_color_temp:
|
if device._is_variable_color_temp:
|
||||||
self._add_feature(
|
self._add_feature(
|
||||||
Feature(
|
Feature(
|
||||||
device=device,
|
device=device,
|
||||||
@ -60,7 +61,7 @@ class Light(IotModule, LightInterface):
|
|||||||
type=Feature.Type.Number,
|
type=Feature.Type.Number,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if self._device._is_color:
|
if device._is_color:
|
||||||
self._add_feature(
|
self._add_feature(
|
||||||
Feature(
|
Feature(
|
||||||
device=device,
|
device=device,
|
||||||
@ -95,13 +96,13 @@ class Light(IotModule, LightInterface):
|
|||||||
return self._device._is_dimmable
|
return self._device._is_dimmable
|
||||||
|
|
||||||
@property # type: ignore
|
@property # type: ignore
|
||||||
def brightness(self) -> int:
|
def brightness(self) -> Annotated[int, FeatureAttribute()]:
|
||||||
"""Return the current brightness in percentage."""
|
"""Return the current brightness in percentage."""
|
||||||
return self._device._brightness
|
return self._device._brightness
|
||||||
|
|
||||||
async def set_brightness(
|
async def set_brightness(
|
||||||
self, brightness: int, *, transition: int | None = None
|
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.
|
"""Set the brightness in percentage. A value of 0 will turn off the light.
|
||||||
|
|
||||||
:param int brightness: brightness in percent
|
:param int brightness: brightness in percent
|
||||||
@ -133,7 +134,7 @@ class Light(IotModule, LightInterface):
|
|||||||
return bulb._has_effects
|
return bulb._has_effects
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hsv(self) -> HSV:
|
def hsv(self) -> Annotated[HSV, FeatureAttribute()]:
|
||||||
"""Return the current HSV state of the bulb.
|
"""Return the current HSV state of the bulb.
|
||||||
|
|
||||||
:return: hue, saturation and value (degrees, %, %)
|
:return: hue, saturation and value (degrees, %, %)
|
||||||
@ -149,7 +150,7 @@ class Light(IotModule, LightInterface):
|
|||||||
value: int | None = None,
|
value: int | None = None,
|
||||||
*,
|
*,
|
||||||
transition: int | None = None,
|
transition: int | None = None,
|
||||||
) -> dict:
|
) -> Annotated[dict, FeatureAttribute()]:
|
||||||
"""Set new HSV.
|
"""Set new HSV.
|
||||||
|
|
||||||
Note, transition is not supported and will be ignored.
|
Note, transition is not supported and will be ignored.
|
||||||
@ -176,7 +177,7 @@ class Light(IotModule, LightInterface):
|
|||||||
return bulb._valid_temperature_range
|
return bulb._valid_temperature_range
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def color_temp(self) -> int:
|
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
|
||||||
"""Whether the bulb supports color temperature changes."""
|
"""Whether the bulb supports color temperature changes."""
|
||||||
if (
|
if (
|
||||||
bulb := self._get_bulb_device()
|
bulb := self._get_bulb_device()
|
||||||
@ -186,7 +187,7 @@ class Light(IotModule, LightInterface):
|
|||||||
|
|
||||||
async def set_color_temp(
|
async def set_color_temp(
|
||||||
self, temp: int, *, brightness: int | None = None, transition: int | None = None
|
self, temp: int, *, brightness: int | None = None, transition: int | None = None
|
||||||
) -> dict:
|
) -> Annotated[dict, FeatureAttribute()]:
|
||||||
"""Set the color temperature of the device in kelvin.
|
"""Set the color temperature of the device in kelvin.
|
||||||
|
|
||||||
Note, transition is not supported and will be ignored.
|
Note, transition is not supported and will be ignored.
|
||||||
@ -242,17 +243,18 @@ class Light(IotModule, LightInterface):
|
|||||||
return self._light_state
|
return self._light_state
|
||||||
|
|
||||||
async def _post_update_hook(self) -> None:
|
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)
|
state = LightState(light_on=False)
|
||||||
else:
|
else:
|
||||||
state = LightState(light_on=True)
|
state = LightState(light_on=True)
|
||||||
if self.is_dimmable:
|
if device._is_dimmable:
|
||||||
state.brightness = self.brightness
|
state.brightness = self.brightness
|
||||||
if self.is_color:
|
if device._is_color:
|
||||||
hsv = self.hsv
|
hsv = self.hsv
|
||||||
state.hue = hsv.hue
|
state.hue = hsv.hue
|
||||||
state.saturation = hsv.saturation
|
state.saturation = hsv.saturation
|
||||||
if self.is_variable_color_temp:
|
if device._is_variable_color_temp:
|
||||||
state.color_temp = self.color_temp
|
state.color_temp = self.color_temp
|
||||||
self._light_state = state
|
self._light_state = state
|
||||||
|
|
||||||
|
@ -85,17 +85,19 @@ class LightPreset(IotModule, LightPresetInterface):
|
|||||||
def preset(self) -> str:
|
def preset(self) -> str:
|
||||||
"""Return current preset name."""
|
"""Return current preset name."""
|
||||||
light = self._device.modules[Module.Light]
|
light = self._device.modules[Module.Light]
|
||||||
|
is_color = light.has_feature("hsv")
|
||||||
|
is_variable_color_temp = light.has_feature("color_temp")
|
||||||
|
|
||||||
brightness = light.brightness
|
brightness = light.brightness
|
||||||
color_temp = light.color_temp if light.is_variable_color_temp else None
|
color_temp = light.color_temp if is_variable_color_temp else None
|
||||||
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, None)
|
|
||||||
|
h, s = (light.hsv.hue, light.hsv.saturation) if is_color else (None, None)
|
||||||
for preset_name, preset in self._presets.items():
|
for preset_name, preset in self._presets.items():
|
||||||
if (
|
if (
|
||||||
preset.brightness == brightness
|
preset.brightness == brightness
|
||||||
and (
|
and (preset.color_temp == color_temp or not is_variable_color_temp)
|
||||||
preset.color_temp == color_temp or not light.is_variable_color_temp
|
and (preset.hue == h or not is_color)
|
||||||
)
|
and (preset.saturation == s or not is_color)
|
||||||
and (preset.hue == h or not light.is_color)
|
|
||||||
and (preset.saturation == s or not light.is_color)
|
|
||||||
):
|
):
|
||||||
return preset_name
|
return preset_name
|
||||||
return self.PRESET_NOT_SET
|
return self.PRESET_NOT_SET
|
||||||
@ -107,7 +109,7 @@ class LightPreset(IotModule, LightPresetInterface):
|
|||||||
"""Set a light preset for the device."""
|
"""Set a light preset for the device."""
|
||||||
light = self._device.modules[Module.Light]
|
light = self._device.modules[Module.Light]
|
||||||
if preset_name == self.PRESET_NOT_SET:
|
if preset_name == self.PRESET_NOT_SET:
|
||||||
if light.is_color:
|
if light.has_feature("hsv"):
|
||||||
preset = LightState(hue=0, saturation=0, brightness=100)
|
preset = LightState(hue=0, saturation=0, brightness=100)
|
||||||
else:
|
else:
|
||||||
preset = LightState(brightness=100)
|
preset = LightState(brightness=100)
|
||||||
|
@ -21,6 +21,9 @@ check for the existence of the module:
|
|||||||
>>> print(light.brightness)
|
>>> print(light.brightness)
|
||||||
100
|
100
|
||||||
|
|
||||||
|
.. include:: ../featureattributes.md
|
||||||
|
:parser: myst_parser.sphinx_
|
||||||
|
|
||||||
To see whether a device supports specific functionality, you can check whether the
|
To see whether a device supports specific functionality, you can check whether the
|
||||||
module has that feature:
|
module has that feature:
|
||||||
|
|
||||||
@ -151,6 +154,9 @@ class Module(ABC):
|
|||||||
)
|
)
|
||||||
TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs")
|
TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs")
|
||||||
|
|
||||||
|
HomeKit: Final[ModuleName[smart.HomeKit]] = ModuleName("HomeKit")
|
||||||
|
Matter: Final[ModuleName[smart.Matter]] = ModuleName("Matter")
|
||||||
|
|
||||||
# SMARTCAM only modules
|
# SMARTCAM only modules
|
||||||
Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera")
|
Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera")
|
||||||
|
|
||||||
|
@ -71,6 +71,13 @@ REDACTORS: dict[str, Callable[[Any], Any] | None] = {
|
|||||||
"map_data": lambda x: "#SCRUBBED_MAPDATA#" if x else "",
|
"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 SmartProtocol(BaseProtocol):
|
||||||
"""Class for the new TPLink SMART protocol."""
|
"""Class for the new TPLink SMART protocol."""
|
||||||
@ -91,6 +98,7 @@ class SmartProtocol(BaseProtocol):
|
|||||||
self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
|
self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
|
||||||
)
|
)
|
||||||
self._redact_data = True
|
self._redact_data = True
|
||||||
|
self._method_missing_logged = False
|
||||||
|
|
||||||
def get_smart_request(self, method: str, params: dict | None = None) -> str:
|
def get_smart_request(self, method: str, params: dict | None = None) -> str:
|
||||||
"""Get a request message as a string."""
|
"""Get a request message as a string."""
|
||||||
@ -180,6 +188,7 @@ class SmartProtocol(BaseProtocol):
|
|||||||
multi_requests = [
|
multi_requests = [
|
||||||
{"method": method, "params": params} if params else {"method": method}
|
{"method": method, "params": params} if params else {"method": method}
|
||||||
for method, params in requests.items()
|
for method, params in requests.items()
|
||||||
|
if method not in FORCE_SINGLE_REQUEST
|
||||||
]
|
]
|
||||||
|
|
||||||
end = len(multi_requests)
|
end = len(multi_requests)
|
||||||
@ -249,17 +258,18 @@ class SmartProtocol(BaseProtocol):
|
|||||||
responses = response_step["result"]["responses"]
|
responses = response_step["result"]["responses"]
|
||||||
for response in responses:
|
for response in responses:
|
||||||
# some smartcam devices calls do not populate the method key
|
# some smartcam devices calls do not populate the method key
|
||||||
# which we can only handle if there's a single request.
|
# these should be defined in DO_NOT_SEND_AS_MULTI_REQUEST.
|
||||||
if not (method := response.get("method")):
|
if not (method := response.get("method")):
|
||||||
if len(requests) == 1:
|
if not self._method_missing_logged:
|
||||||
method = next(iter(requests))
|
# Avoid spamming the logs
|
||||||
else:
|
self._method_missing_logged = True
|
||||||
_LOGGER.debug(
|
_LOGGER.error(
|
||||||
"No method key in response for %s, skipping: %s",
|
"No method key in response for %s, skipping: %s",
|
||||||
self._host,
|
self._host,
|
||||||
response,
|
response_step,
|
||||||
)
|
)
|
||||||
continue
|
# These will end up being queried individually
|
||||||
|
continue
|
||||||
|
|
||||||
self._handle_response_error_code(
|
self._handle_response_error_code(
|
||||||
response, method, raise_on_error=raise_on_error
|
response, method, raise_on_error=raise_on_error
|
||||||
@ -269,7 +279,9 @@ class SmartProtocol(BaseProtocol):
|
|||||||
result, method, retry_count=retry_count
|
result, method, retry_count=retry_count
|
||||||
)
|
)
|
||||||
multi_result[method] = result
|
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():
|
for method, params in requests.items():
|
||||||
if method not in multi_result:
|
if method not in multi_result:
|
||||||
resp = await self._transport.send(
|
resp = await self._transport.send(
|
||||||
|
@ -16,6 +16,7 @@ from .energy import Energy
|
|||||||
from .fan import Fan
|
from .fan import Fan
|
||||||
from .firmware import Firmware
|
from .firmware import Firmware
|
||||||
from .frostprotection import FrostProtection
|
from .frostprotection import FrostProtection
|
||||||
|
from .homekit import HomeKit
|
||||||
from .humiditysensor import HumiditySensor
|
from .humiditysensor import HumiditySensor
|
||||||
from .led import Led
|
from .led import Led
|
||||||
from .light import Light
|
from .light import Light
|
||||||
@ -23,6 +24,7 @@ from .lighteffect import LightEffect
|
|||||||
from .lightpreset import LightPreset
|
from .lightpreset import LightPreset
|
||||||
from .lightstripeffect import LightStripEffect
|
from .lightstripeffect import LightStripEffect
|
||||||
from .lighttransition import LightTransition
|
from .lighttransition import LightTransition
|
||||||
|
from .matter import Matter
|
||||||
from .motionsensor import MotionSensor
|
from .motionsensor import MotionSensor
|
||||||
from .overheatprotection import OverheatProtection
|
from .overheatprotection import OverheatProtection
|
||||||
from .reportmode import ReportMode
|
from .reportmode import ReportMode
|
||||||
@ -66,4 +68,6 @@ __all__ = [
|
|||||||
"Thermostat",
|
"Thermostat",
|
||||||
"SmartLightEffect",
|
"SmartLightEffect",
|
||||||
"OverheatProtection",
|
"OverheatProtection",
|
||||||
|
"HomeKit",
|
||||||
|
"Matter",
|
||||||
]
|
]
|
||||||
|
32
kasa/smart/modules/homekit.py
Normal file
32
kasa/smart/modules/homekit.py
Normal file
@ -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
|
@ -55,7 +55,7 @@ class Light(SmartModule, LightInterface):
|
|||||||
|
|
||||||
:return: White temperature range in Kelvin (minimum, maximum)
|
: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")
|
raise KasaException("Color temperature not supported")
|
||||||
|
|
||||||
return self._device.modules[Module.ColorTemperature].valid_temperature_range
|
return self._device.modules[Module.ColorTemperature].valid_temperature_range
|
||||||
@ -66,7 +66,7 @@ class Light(SmartModule, LightInterface):
|
|||||||
|
|
||||||
:return: hue, saturation and value (degrees, %, %)
|
: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.")
|
raise KasaException("Bulb does not support color.")
|
||||||
|
|
||||||
return self._device.modules[Module.Color].hsv
|
return self._device.modules[Module.Color].hsv
|
||||||
@ -74,7 +74,7 @@ class Light(SmartModule, LightInterface):
|
|||||||
@property
|
@property
|
||||||
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
|
def color_temp(self) -> Annotated[int, FeatureAttribute()]:
|
||||||
"""Whether the bulb supports color temperature changes."""
|
"""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.")
|
raise KasaException("Bulb does not support colortemp.")
|
||||||
|
|
||||||
return self._device.modules[Module.ColorTemperature].color_temp
|
return self._device.modules[Module.ColorTemperature].color_temp
|
||||||
@ -82,7 +82,7 @@ class Light(SmartModule, LightInterface):
|
|||||||
@property
|
@property
|
||||||
def brightness(self) -> Annotated[int, FeatureAttribute()]:
|
def brightness(self) -> Annotated[int, FeatureAttribute()]:
|
||||||
"""Return the current brightness in percentage."""
|
"""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.")
|
raise KasaException("Bulb is not dimmable.")
|
||||||
|
|
||||||
return self._device.modules[Module.Brightness].brightness
|
return self._device.modules[Module.Brightness].brightness
|
||||||
@ -104,7 +104,7 @@ class Light(SmartModule, LightInterface):
|
|||||||
:param int value: value between 1 and 100
|
:param int value: value between 1 and 100
|
||||||
:param int transition: transition in milliseconds.
|
: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.")
|
raise KasaException("Bulb does not support color.")
|
||||||
|
|
||||||
return await self._device.modules[Module.Color].set_hsv(hue, saturation, value)
|
return await self._device.modules[Module.Color].set_hsv(hue, saturation, value)
|
||||||
@ -119,7 +119,7 @@ class Light(SmartModule, LightInterface):
|
|||||||
:param int temp: The new color temperature, in Kelvin
|
:param int temp: The new color temperature, in Kelvin
|
||||||
:param int transition: transition in milliseconds.
|
: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.")
|
raise KasaException("Bulb does not support colortemp.")
|
||||||
return await self._device.modules[Module.ColorTemperature].set_color_temp(
|
return await self._device.modules[Module.ColorTemperature].set_color_temp(
|
||||||
temp, brightness=brightness
|
temp, brightness=brightness
|
||||||
@ -135,7 +135,7 @@ class Light(SmartModule, LightInterface):
|
|||||||
:param int brightness: brightness in percent
|
:param int brightness: brightness in percent
|
||||||
:param int transition: transition in milliseconds.
|
: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.")
|
raise KasaException("Bulb is not dimmable.")
|
||||||
|
|
||||||
return await self._device.modules[Module.Brightness].set_brightness(brightness)
|
return await self._device.modules[Module.Brightness].set_brightness(brightness)
|
||||||
@ -167,16 +167,17 @@ class Light(SmartModule, LightInterface):
|
|||||||
return self._light_state
|
return self._light_state
|
||||||
|
|
||||||
async def _post_update_hook(self) -> None:
|
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)
|
state = LightState(light_on=False)
|
||||||
else:
|
else:
|
||||||
state = LightState(light_on=True)
|
state = LightState(light_on=True)
|
||||||
if self.is_dimmable:
|
if Module.Brightness in device.modules:
|
||||||
state.brightness = self.brightness
|
state.brightness = self.brightness
|
||||||
if self.is_color:
|
if Module.Color in device.modules:
|
||||||
hsv = self.hsv
|
hsv = self.hsv
|
||||||
state.hue = hsv.hue
|
state.hue = hsv.hue
|
||||||
state.saturation = hsv.saturation
|
state.saturation = hsv.saturation
|
||||||
if self.is_variable_color_temp:
|
if Module.ColorTemperature in device.modules:
|
||||||
state.color_temp = self.color_temp
|
state.color_temp = self.color_temp
|
||||||
self._light_state = state
|
self._light_state = state
|
||||||
|
@ -96,13 +96,18 @@ class LightPreset(SmartModule, LightPresetInterface):
|
|||||||
"""Return current preset name."""
|
"""Return current preset name."""
|
||||||
light = self._device.modules[SmartModule.Light]
|
light = self._device.modules[SmartModule.Light]
|
||||||
brightness = light.brightness
|
brightness = light.brightness
|
||||||
color_temp = light.color_temp if light.is_variable_color_temp else None
|
color_temp = light.color_temp if light.has_feature("color_temp") else None
|
||||||
h, s = (light.hsv.hue, light.hsv.saturation) if light.is_color else (None, 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():
|
for preset_name, preset in self._presets.items():
|
||||||
if (
|
if (
|
||||||
preset.brightness == brightness
|
preset.brightness == brightness
|
||||||
and (
|
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.hue == h
|
||||||
and preset.saturation == s
|
and preset.saturation == s
|
||||||
@ -117,7 +122,7 @@ class LightPreset(SmartModule, LightPresetInterface):
|
|||||||
"""Set a light preset for the device."""
|
"""Set a light preset for the device."""
|
||||||
light = self._device.modules[SmartModule.Light]
|
light = self._device.modules[SmartModule.Light]
|
||||||
if preset_name == self.PRESET_NOT_SET:
|
if preset_name == self.PRESET_NOT_SET:
|
||||||
if light.is_color:
|
if light.has_feature("hsv"):
|
||||||
preset = LightState(hue=0, saturation=0, brightness=100)
|
preset = LightState(hue=0, saturation=0, brightness=100)
|
||||||
else:
|
else:
|
||||||
preset = LightState(brightness=100)
|
preset = LightState(brightness=100)
|
||||||
|
43
kasa/smart/modules/matter.py
Normal file
43
kasa/smart/modules/matter.py
Normal file
@ -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
|
@ -6,6 +6,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from ..device import DeviceInfo
|
||||||
from ..device_type import DeviceType
|
from ..device_type import DeviceType
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
|
from ..protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper
|
||||||
@ -50,6 +51,22 @@ class SmartChildDevice(SmartDevice):
|
|||||||
self._components_raw = component_info_raw
|
self._components_raw = component_info_raw
|
||||||
self._components = self._parse_components(self._components_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:
|
async def update(self, update_children: bool = True) -> None:
|
||||||
"""Update child module info.
|
"""Update child module info.
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ from collections.abc import Mapping, Sequence
|
|||||||
from datetime import UTC, datetime, timedelta, tzinfo
|
from datetime import UTC, datetime, timedelta, tzinfo
|
||||||
from typing import TYPE_CHECKING, Any, TypeAlias, cast
|
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 ..device_type import DeviceType
|
||||||
from ..deviceconfig import DeviceConfig
|
from ..deviceconfig import DeviceConfig
|
||||||
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
|
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
|
||||||
@ -69,7 +69,6 @@ class SmartDevice(Device):
|
|||||||
self._modules: dict[str | ModuleName[Module], SmartModule] = {}
|
self._modules: dict[str | ModuleName[Module], SmartModule] = {}
|
||||||
self._parent: SmartDevice | None = None
|
self._parent: SmartDevice | None = None
|
||||||
self._children: Mapping[str, SmartDevice] = {}
|
self._children: Mapping[str, SmartDevice] = {}
|
||||||
self._last_update = {}
|
|
||||||
self._last_update_time: float | None = None
|
self._last_update_time: float | None = None
|
||||||
self._on_since: datetime | None = None
|
self._on_since: datetime | None = None
|
||||||
self._info: dict[str, Any] = {}
|
self._info: dict[str, Any] = {}
|
||||||
@ -497,18 +496,13 @@ class SmartDevice(Device):
|
|||||||
@property
|
@property
|
||||||
def model(self) -> str:
|
def model(self) -> str:
|
||||||
"""Returns the device model."""
|
"""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
|
disco_model = str(self._info.get("device_model"))
|
||||||
def _model_region(self) -> str:
|
long_name, _, _ = disco_model.partition("(")
|
||||||
"""Return device full model name and region."""
|
return long_name
|
||||||
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
|
@property
|
||||||
def alias(self) -> str | None:
|
def alias(self) -> str | None:
|
||||||
@ -808,7 +802,7 @@ class SmartDevice(Device):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_device_info(
|
def _get_device_info(
|
||||||
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
||||||
) -> _DeviceInfo:
|
) -> DeviceInfo:
|
||||||
"""Get model information for a device."""
|
"""Get model information for a device."""
|
||||||
di = info["get_device_info"]
|
di = info["get_device_info"]
|
||||||
components = [comp["id"] for comp in info["component_nego"]["component_list"]]
|
components = [comp["id"] for comp in info["component_nego"]["component_list"]]
|
||||||
@ -837,7 +831,7 @@ class SmartDevice(Device):
|
|||||||
# Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc.
|
# Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc.
|
||||||
brand = devicetype[:4].lower()
|
brand = devicetype[:4].lower()
|
||||||
|
|
||||||
return _DeviceInfo(
|
return DeviceInfo(
|
||||||
short_name=short_name,
|
short_name=short_name,
|
||||||
long_name=long_name,
|
long_name=long_name,
|
||||||
brand=brand,
|
brand=brand,
|
||||||
|
@ -57,7 +57,7 @@ class SmartModule(Module):
|
|||||||
#: Module is initialized, if any of the given keys exists in the sysinfo
|
#: Module is initialized, if any of the given keys exists in the sysinfo
|
||||||
SYSINFO_LOOKUP_KEYS: list[str] = []
|
SYSINFO_LOOKUP_KEYS: list[str] = []
|
||||||
#: Query to execute during the main update cycle
|
#: Query to execute during the main update cycle
|
||||||
QUERY_GETTER_NAME: str
|
QUERY_GETTER_NAME: str = ""
|
||||||
|
|
||||||
REGISTERED_MODULES: dict[str, type[SmartModule]] = {}
|
REGISTERED_MODULES: dict[str, type[SmartModule]] = {}
|
||||||
|
|
||||||
@ -138,7 +138,9 @@ class SmartModule(Module):
|
|||||||
|
|
||||||
Default implementation uses the raw query getter w/o parameters.
|
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:
|
async def call(self, method: str, params: dict | None = None) -> dict:
|
||||||
"""Call a method.
|
"""Call a method.
|
||||||
|
@ -4,7 +4,9 @@ from .alarm import Alarm
|
|||||||
from .camera import Camera
|
from .camera import Camera
|
||||||
from .childdevice import ChildDevice
|
from .childdevice import ChildDevice
|
||||||
from .device import DeviceModule
|
from .device import DeviceModule
|
||||||
|
from .homekit import HomeKit
|
||||||
from .led import Led
|
from .led import Led
|
||||||
|
from .matter import Matter
|
||||||
from .pantilt import PanTilt
|
from .pantilt import PanTilt
|
||||||
from .time import Time
|
from .time import Time
|
||||||
|
|
||||||
@ -16,4 +18,6 @@ __all__ = [
|
|||||||
"Led",
|
"Led",
|
||||||
"PanTilt",
|
"PanTilt",
|
||||||
"Time",
|
"Time",
|
||||||
|
"HomeKit",
|
||||||
|
"Matter",
|
||||||
]
|
]
|
||||||
|
16
kasa/smartcam/modules/homekit.py
Normal file
16
kasa/smartcam/modules/homekit.py
Normal file
@ -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 {}
|
44
kasa/smartcam/modules/matter.py
Normal file
44
kasa/smartcam/modules/matter.py
Normal file
@ -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
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from ..device import _DeviceInfo
|
from ..device import DeviceInfo
|
||||||
from ..device_type import DeviceType
|
from ..device_type import DeviceType
|
||||||
from ..module import Module
|
from ..module import Module
|
||||||
from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper
|
from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper
|
||||||
@ -37,7 +37,7 @@ class SmartCamDevice(SmartDevice):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_device_info(
|
def _get_device_info(
|
||||||
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
info: dict[str, Any], discovery_info: dict[str, Any] | None
|
||||||
) -> _DeviceInfo:
|
) -> DeviceInfo:
|
||||||
"""Get model information for a device."""
|
"""Get model information for a device."""
|
||||||
basic_info = info["getDeviceInfo"]["device_info"]["basic_info"]
|
basic_info = info["getDeviceInfo"]["device_info"]["basic_info"]
|
||||||
short_name = basic_info["device_model"]
|
short_name = basic_info["device_model"]
|
||||||
@ -45,7 +45,7 @@ class SmartCamDevice(SmartDevice):
|
|||||||
device_type = SmartCamDevice._get_device_type_from_sysinfo(basic_info)
|
device_type = SmartCamDevice._get_device_type_from_sysinfo(basic_info)
|
||||||
fw_version_full = basic_info["sw_version"]
|
fw_version_full = basic_info["sw_version"]
|
||||||
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
|
firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1)
|
||||||
return _DeviceInfo(
|
return DeviceInfo(
|
||||||
short_name=basic_info["device_model"],
|
short_name=basic_info["device_model"],
|
||||||
long_name=long_name,
|
long_name=long_name,
|
||||||
brand="tapo",
|
brand="tapo",
|
||||||
@ -248,8 +248,8 @@ class SmartCamDevice(SmartDevice):
|
|||||||
def hw_info(self) -> dict:
|
def hw_info(self) -> dict:
|
||||||
"""Return hardware info for the device."""
|
"""Return hardware info for the device."""
|
||||||
return {
|
return {
|
||||||
"sw_ver": self._info.get("hw_ver"),
|
"sw_ver": self._info.get("fw_ver"),
|
||||||
"hw_ver": self._info.get("fw_ver"),
|
"hw_ver": self._info.get("hw_ver"),
|
||||||
"mac": self._info.get("mac"),
|
"mac": self._info.get("mac"),
|
||||||
"type": self._info.get("type"),
|
"type": self._info.get("type"),
|
||||||
"hwId": self._info.get("hwId"),
|
"hwId": self._info.get("hwId"),
|
||||||
|
@ -21,8 +21,6 @@ class SmartCamModule(SmartModule):
|
|||||||
|
|
||||||
SmartCamAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCamAlarm")
|
SmartCamAlarm: Final[ModuleName[modules.Alarm]] = ModuleName("SmartCamAlarm")
|
||||||
|
|
||||||
#: Query to execute during the main update cycle
|
|
||||||
QUERY_GETTER_NAME: str
|
|
||||||
#: Module name to be queried
|
#: Module name to be queried
|
||||||
QUERY_MODULE_NAME: str
|
QUERY_MODULE_NAME: str
|
||||||
#: Section name or names to be queried
|
#: Section name or names to be queried
|
||||||
@ -37,6 +35,8 @@ class SmartCamModule(SmartModule):
|
|||||||
|
|
||||||
Default implementation uses the raw query getter w/o parameters.
|
Default implementation uses the raw query getter w/o parameters.
|
||||||
"""
|
"""
|
||||||
|
if not self.QUERY_GETTER_NAME:
|
||||||
|
return {}
|
||||||
section_names = (
|
section_names = (
|
||||||
{"name": self.QUERY_SECTION_NAMES} if self.QUERY_SECTION_NAMES else {}
|
{"name": self.QUERY_SECTION_NAMES} if self.QUERY_SECTION_NAMES else {}
|
||||||
)
|
)
|
||||||
@ -86,7 +86,8 @@ class SmartCamModule(SmartModule):
|
|||||||
f" for '{self._module}'"
|
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:
|
else:
|
||||||
found = {key: val for key, val in dev._last_update.items() if key in q}
|
found = {key: val for key, val in dev._last_update.items() if key in q}
|
||||||
for key in q:
|
for key in q:
|
||||||
|
@ -473,8 +473,12 @@ def get_nearest_fixture_to_ip(dev):
|
|||||||
assert protocol_fixtures, "Unknown device type"
|
assert protocol_fixtures, "Unknown device type"
|
||||||
|
|
||||||
# This will get the best fixture with a match on model region
|
# This will get the best fixture with a match on model region
|
||||||
if model_region_fixtures := filter_fixtures(
|
if (di := dev.device_info) and (
|
||||||
"", model_filter={dev._model_region}, fixture_list=protocol_fixtures
|
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))
|
return next(iter(model_region_fixtures))
|
||||||
|
|
||||||
|
@ -114,6 +114,15 @@ class FakeSmartTransport(BaseTransport):
|
|||||||
"type": 0,
|
"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": (
|
"get_auto_update_info": (
|
||||||
"firmware",
|
"firmware",
|
||||||
{"enable": True, "random_range": 120, "time": 180},
|
{"enable": True, "random_range": 120, "time": 180},
|
||||||
@ -151,6 +160,13 @@ class FakeSmartTransport(BaseTransport):
|
|||||||
"energy_monitoring",
|
"energy_monitoring",
|
||||||
{"igain": 10861, "vgain": 118657},
|
{"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):
|
async def send(self, request: str):
|
||||||
|
@ -34,6 +34,7 @@ class FakeSmartCamTransport(BaseTransport):
|
|||||||
list_return_size=10,
|
list_return_size=10,
|
||||||
is_child=False,
|
is_child=False,
|
||||||
verbatim=False,
|
verbatim=False,
|
||||||
|
components_not_included=False,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=DeviceConfig(
|
config=DeviceConfig(
|
||||||
@ -44,6 +45,7 @@ class FakeSmartCamTransport(BaseTransport):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.fixture_name = fixture_name
|
self.fixture_name = fixture_name
|
||||||
# When True verbatim will bypass any extra processing of missing
|
# When True verbatim will bypass any extra processing of missing
|
||||||
# methods and is used to test the fixture creation itself.
|
# methods and is used to test the fixture creation itself.
|
||||||
@ -58,6 +60,17 @@ class FakeSmartCamTransport(BaseTransport):
|
|||||||
# self.child_protocols = self._get_child_protocols()
|
# self.child_protocols = self._get_child_protocols()
|
||||||
self.list_return_size = list_return_size
|
self.list_return_size = list_return_size
|
||||||
|
|
||||||
|
# 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
|
@property
|
||||||
def default_port(self):
|
def default_port(self):
|
||||||
"""Default port for the transport."""
|
"""Default port for the transport."""
|
||||||
@ -112,6 +125,15 @@ class FakeSmartCamTransport(BaseTransport):
|
|||||||
info = info[key]
|
info = info[key]
|
||||||
info[set_keys[-1]] = value
|
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 for when there's not a simple mapping of setters to getters
|
||||||
SETTERS = {
|
SETTERS = {
|
||||||
("system", "sys", "dev_alias"): [
|
("system", "sys", "dev_alias"): [
|
||||||
@ -217,8 +239,17 @@ class FakeSmartCamTransport(BaseTransport):
|
|||||||
start_index : start_index + self.list_return_size
|
start_index : start_index + self.list_return_size
|
||||||
]
|
]
|
||||||
return {"result": result, "error_code": 0}
|
return {"result": result, "error_code": 0}
|
||||||
else:
|
if (
|
||||||
return {"error_code": -1}
|
# 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}
|
return {"error_code": -1}
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
@ -145,12 +145,21 @@ def filter_fixtures(
|
|||||||
def _component_match(
|
def _component_match(
|
||||||
fixture_data: FixtureInfo, component_filter: str | ComponentFilter
|
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
|
return False
|
||||||
components = {
|
|
||||||
component["id"]: component["ver_code"]
|
|
||||||
for component in component_nego["component_list"]
|
|
||||||
}
|
|
||||||
if isinstance(component_filter, str):
|
if isinstance(component_filter, str):
|
||||||
return component_filter in components
|
return component_filter in components
|
||||||
else:
|
else:
|
||||||
|
@ -91,7 +91,9 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog):
|
|||||||
monkeypatch.setitem(dev._sys_info, "model", "unknown bulb")
|
monkeypatch.setitem(dev._sys_info, "model", "unknown bulb")
|
||||||
light = dev.modules.get(Module.Light)
|
light = dev.modules.get(Module.Light)
|
||||||
assert 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
|
assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@ -99,7 +99,7 @@ async def test_invalid_connection(mocker, dev):
|
|||||||
@has_emeter_iot
|
@has_emeter_iot
|
||||||
async def test_initial_update_emeter(dev, mocker):
|
async def test_initial_update_emeter(dev, mocker):
|
||||||
"""Test that the initial update performs second query if emeter is available."""
|
"""Test that the initial update performs second query if emeter is available."""
|
||||||
dev._last_update = None
|
dev._last_update = {}
|
||||||
dev._legacy_features = set()
|
dev._legacy_features = set()
|
||||||
spy = mocker.spy(dev.protocol, "query")
|
spy = mocker.spy(dev.protocol, "query")
|
||||||
await dev.update()
|
await dev.update()
|
||||||
@ -111,7 +111,7 @@ async def test_initial_update_emeter(dev, mocker):
|
|||||||
@no_emeter_iot
|
@no_emeter_iot
|
||||||
async def test_initial_update_no_emeter(dev, mocker):
|
async def test_initial_update_no_emeter(dev, mocker):
|
||||||
"""Test that the initial update performs second query if emeter is available."""
|
"""Test that the initial update performs second query if emeter is available."""
|
||||||
dev._last_update = None
|
dev._last_update = {}
|
||||||
dev._legacy_features = set()
|
dev._legacy_features = set()
|
||||||
spy = mocker.spy(dev.protocol, "query")
|
spy = mocker.spy(dev.protocol, "query")
|
||||||
await dev.update()
|
await dev.update()
|
||||||
|
@ -2,6 +2,7 @@ import logging
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_mock
|
import pytest_mock
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from kasa.exceptions import (
|
from kasa.exceptions import (
|
||||||
SMART_RETRYABLE_ERRORS,
|
SMART_RETRYABLE_ERRORS,
|
||||||
@ -14,6 +15,7 @@ from kasa.smart import SmartDevice
|
|||||||
|
|
||||||
from ..conftest import device_smart
|
from ..conftest import device_smart
|
||||||
from ..fakeprotocol_smart import FakeSmartTransport
|
from ..fakeprotocol_smart import FakeSmartTransport
|
||||||
|
from ..fakeprotocol_smartcam import FakeSmartCamTransport
|
||||||
|
|
||||||
DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}}
|
DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}}
|
||||||
DUMMY_MULTIPLE_QUERY = {
|
DUMMY_MULTIPLE_QUERY = {
|
||||||
@ -448,3 +450,81 @@ async def test_smart_queries_redaction(
|
|||||||
await dev.update()
|
await dev.update()
|
||||||
assert device_id not in caplog.text
|
assert device_id not in caplog.text
|
||||||
assert "REDACTED_" + device_id[9::] 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
|
||||||
|
16
tests/smart/modules/test_homekit.py
Normal file
16
tests/smart/modules/test_homekit.py
Normal file
@ -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
|
20
tests/smart/modules/test_matter.py
Normal file
20
tests/smart/modules/test_matter.py
Normal file
@ -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
|
@ -62,11 +62,14 @@ async def test_device_type_no_update(discovery_mock, caplog: pytest.LogCaptureFi
|
|||||||
assert repr(dev) == f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - update() needed>"
|
assert repr(dev) == f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - update() needed>"
|
||||||
|
|
||||||
discovery_result = copy.deepcopy(discovery_mock.discovery_data["result"])
|
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)
|
dev.update_from_discover_info(discovery_result)
|
||||||
assert dev.device_type is DeviceType.Unknown
|
assert dev.device_type is DeviceType.Unknown
|
||||||
assert (
|
assert (
|
||||||
repr(dev)
|
repr(dev)
|
||||||
== f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - None (None) - update() needed>"
|
== f"<DeviceType.Unknown at {DISCOVERY_MOCK_IP} - None ({short_model}) - update() needed>"
|
||||||
)
|
)
|
||||||
discovery_result["device_type"] = "SMART.FOOBAR"
|
discovery_result["device_type"] = "SMART.FOOBAR"
|
||||||
dev.update_from_discover_info(discovery_result)
|
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 dev.device_type is DeviceType.Plug
|
||||||
assert (
|
assert (
|
||||||
repr(dev)
|
repr(dev)
|
||||||
== f"<DeviceType.Plug at {DISCOVERY_MOCK_IP} - None (None) - update() needed>"
|
== f"<DeviceType.Plug at {DISCOVERY_MOCK_IP} - None ({short_model}) - update() needed>"
|
||||||
)
|
)
|
||||||
assert "Unknown device type, falling back to plug" in caplog.text
|
assert "Unknown device type, falling back to plug" in caplog.text
|
||||||
|
|
||||||
@ -469,7 +472,9 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt
|
|||||||
async def test_smart_temp_range(dev: Device):
|
async def test_smart_temp_range(dev: Device):
|
||||||
light = dev.modules.get(Module.Light)
|
light = dev.modules.get(Module.Light)
|
||||||
assert 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
|
@device_smart
|
||||||
@ -528,3 +533,16 @@ async def test_initialize_modules_required_component(
|
|||||||
|
|
||||||
assert "AvailableComponent" in dev.modules
|
assert "AvailableComponent" in dev.modules
|
||||||
assert "NonExistingComponent" not 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() == {}
|
||||||
|
@ -25,7 +25,7 @@ async def test_hsv(dev: Device, turn_on):
|
|||||||
light = dev.modules.get(Module.Light)
|
light = dev.modules.get(Module.Light)
|
||||||
assert light
|
assert light
|
||||||
await handle_turn_on(dev, turn_on)
|
await handle_turn_on(dev, turn_on)
|
||||||
assert light.is_color
|
assert light.has_feature("hsv")
|
||||||
|
|
||||||
hue, saturation, brightness = light.hsv
|
hue, saturation, brightness = light.hsv
|
||||||
assert 0 <= hue <= 360
|
assert 0 <= hue <= 360
|
||||||
@ -106,7 +106,7 @@ async def test_invalid_hsv(
|
|||||||
light = dev.modules.get(Module.Light)
|
light = dev.modules.get(Module.Light)
|
||||||
assert light
|
assert light
|
||||||
await handle_turn_on(dev, turn_on)
|
await handle_turn_on(dev, turn_on)
|
||||||
assert light.is_color
|
assert light.has_feature("hsv")
|
||||||
with pytest.raises(exception_cls, match=error):
|
with pytest.raises(exception_cls, match=error):
|
||||||
await light.set_hsv(hue, sat, brightness)
|
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):
|
async def test_hsv_on_non_color(dev: Device):
|
||||||
light = dev.modules.get(Module.Light)
|
light = dev.modules.get(Module.Light)
|
||||||
assert light
|
assert light
|
||||||
assert not light.is_color
|
assert not light.has_feature("hsv")
|
||||||
|
|
||||||
with pytest.raises(KasaException):
|
with pytest.raises(KasaException):
|
||||||
await light.set_hsv(0, 0, 0)
|
await light.set_hsv(0, 0, 0)
|
||||||
@ -173,9 +173,6 @@ async def test_non_variable_temp(dev: Device):
|
|||||||
with pytest.raises(KasaException):
|
with pytest.raises(KasaException):
|
||||||
await light.set_color_temp(2700)
|
await light.set_color_temp(2700)
|
||||||
|
|
||||||
with pytest.raises(KasaException):
|
|
||||||
print(light.valid_temperature_range)
|
|
||||||
|
|
||||||
with pytest.raises(KasaException):
|
with pytest.raises(KasaException):
|
||||||
print(light.color_temp)
|
print(light.color_temp)
|
||||||
|
|
||||||
|
@ -12,6 +12,7 @@ from pytest_mock import MockerFixture
|
|||||||
|
|
||||||
from kasa import (
|
from kasa import (
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
|
ColorTempRange,
|
||||||
Credentials,
|
Credentials,
|
||||||
Device,
|
Device,
|
||||||
DeviceError,
|
DeviceError,
|
||||||
@ -523,7 +524,9 @@ async def test_emeter(dev: Device, mocker, runner):
|
|||||||
|
|
||||||
async def test_brightness(dev: Device, runner):
|
async def test_brightness(dev: Device, runner):
|
||||||
res = await runner.invoke(brightness, obj=dev)
|
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
|
assert "This device does not support brightness." in res.output
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -540,13 +543,16 @@ async def test_brightness(dev: Device, runner):
|
|||||||
|
|
||||||
async def test_color_temperature(dev: Device, runner):
|
async def test_color_temperature(dev: Device, runner):
|
||||||
res = await runner.invoke(temperature, obj=dev)
|
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
|
assert "Device does not support color temperature" in res.output
|
||||||
return
|
return
|
||||||
|
|
||||||
res = await runner.invoke(temperature, obj=dev)
|
res = await runner.invoke(temperature, obj=dev)
|
||||||
assert f"Color temperature: {light.color_temp}" in res.output
|
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
|
assert f"(min: {valid_range.min}, max: {valid_range.max})" in res.output
|
||||||
|
|
||||||
val = int((valid_range.min + valid_range.max) / 2)
|
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):
|
async def test_color_hsv(dev: Device, runner: CliRunner):
|
||||||
res = await runner.invoke(hsv, obj=dev)
|
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
|
assert "Device does not support colors" in res.output
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -198,7 +198,7 @@ async def test_light_color_temp(dev: Device):
|
|||||||
|
|
||||||
light = next(get_parent_and_child_modules(dev, Module.Light))
|
light = next(get_parent_and_child_modules(dev, Module.Light))
|
||||||
assert light
|
assert light
|
||||||
if not light.is_variable_color_temp:
|
if not light.has_feature("color_temp"):
|
||||||
pytest.skip(
|
pytest.skip(
|
||||||
"Some smart light strips have color_temperature"
|
"Some smart light strips have color_temperature"
|
||||||
" component but min and max are the same"
|
" component but min and max are the same"
|
||||||
|
@ -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_color", bool(light), "Light")
|
||||||
await _test_attribute(dev, "is_variable_color_temp", 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, "brightness", bool(light), "Light", will_raise=exc)
|
||||||
await _test_attribute(
|
await _test_attribute(
|
||||||
dev, "set_brightness", bool(light), "Light", 50, will_raise=exc
|
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, "hsv", bool(light), "Light", will_raise=exc)
|
||||||
await _test_attribute(
|
await _test_attribute(
|
||||||
dev, "set_hsv", bool(light), "Light", 50, 50, 50, will_raise=exc
|
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, "color_temp", bool(light), "Light", will_raise=exc)
|
||||||
await _test_attribute(
|
await _test_attribute(
|
||||||
dev, "set_color_temp", bool(light), "Light", 2700, will_raise=exc
|
dev, "set_color_temp", bool(light), "Light", 2700, will_raise=exc
|
||||||
|
@ -390,13 +390,12 @@ async def test_device_update_from_new_discovery_info(discovery_mock):
|
|||||||
device_class = Discover._get_device_class(discovery_data)
|
device_class = Discover._get_device_class(discovery_data)
|
||||||
device = device_class("127.0.0.1")
|
device = device_class("127.0.0.1")
|
||||||
discover_info = DiscoveryResult.from_dict(discovery_data["result"])
|
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("-", ":")
|
device.update_from_discover_info(discovery_data["result"])
|
||||||
assert device.model == model
|
|
||||||
|
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
|
# TODO implement requires_update for SmartDevice
|
||||||
if isinstance(device, IotDevice):
|
if isinstance(device, IotDevice):
|
||||||
|
@ -19,7 +19,7 @@ def test_bulb_examples(mocker):
|
|||||||
assert not res["failed"]
|
assert not res["failed"]
|
||||||
|
|
||||||
|
|
||||||
def test_smartdevice_examples(mocker):
|
def test_iotdevice_examples(mocker):
|
||||||
"""Use HS110 for emeter examples."""
|
"""Use HS110 for emeter examples."""
|
||||||
p = asyncio.run(get_device_for_fixture_protocol("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"))
|
||||||
asyncio.run(p.set_alias("Bedroom Lamp Plug"))
|
asyncio.run(p.set_alias("Bedroom Lamp Plug"))
|
||||||
|
Loading…
Reference in New Issue
Block a user