From b66a337f40a82d0110a3a789cb5d0b9a23e504c8 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 May 2024 20:56:03 +0200 Subject: [PATCH 01/11] Add H100 1.5.10 and KE100 2.4.0 fixtures (#905) --- SUPPORTED.md | 2 + .../fixtures/smart/H100(EU)_1.0_1.5.10.json | 547 ++++++++++++++++++ .../smart/child/KE100(EU)_1.0_2.4.0.json | 170 ++++++ 3 files changed, 719 insertions(+) create mode 100644 kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json create mode 100644 kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json diff --git a/SUPPORTED.md b/SUPPORTED.md index e5269763..451efe68 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -137,6 +137,7 @@ Some newer Kasa devices require authentication. These are marked with *\* - Hardware: 1.0 (EU) / Firmware: 2.8.0\* - Hardware: 1.0 (UK) / Firmware: 2.8.0\* @@ -208,6 +209,7 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros - **H100** - Hardware: 1.0 (EU) / Firmware: 1.2.3 + - Hardware: 1.0 (EU) / Firmware: 1.5.10 - Hardware: 1.0 (EU) / Firmware: 1.5.5 ### Hub-Connected Devices diff --git a/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json b/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json new file mode 100644 index 00000000..021309c7 --- /dev/null +++ b/kasa/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json @@ -0,0 +1,547 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "child_device", + "ver_code": 1 + }, + { + "id": "child_quick_setup", + "ver_code": 1 + }, + { + "id": "child_inherit", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 1 + }, + { + "id": "alarm", + "ver_code": 1 + }, + { + "id": "device_load", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "alarm_logs", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 3 + }, + { + "id": "chime", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "H100(EU)", + "device_type": "SMART.TAPOHUB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_alarm_configure": { + "duration": 10, + "type": "Alarm 1", + "volume": "high" + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "humidity", + "ver_code": 1 + }, + { + "id": "temp_humidity_record", + "ver_code": 1 + }, + { + "id": "comfort_temperature", + "ver_code": 1 + }, + { + "id": "comfort_humidity", + "ver_code": 1 + }, + { + "id": "report_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 5, + "category": "subg.trv", + "child_protection": false, + "current_temp": 22.9, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "frost_protection_on": false, + "fw_ver": "2.4.0 Build 230804 Rel.193040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -7, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 23.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 62, + "current_humidity_exception": 2, + "current_temp": 24.0, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.7.0 Build 230424 Rel.170332", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -115, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706990901, + "mac": "F0A731000000", + "model": "T315", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -38, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "hub", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.10 Build 240207 Rel.175759", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "H100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -60, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOHUB" + }, + "get_device_load_info": { + "cur_load_num": 4, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1451 + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1714669215 + }, + "get_device_usage": {}, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.5.10 Build 240207 Rel.175759", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 358, + "night_mode_type": "sunrise_sunset", + "start_time": 1259, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_support_alarm_type_list": { + "alarm_type_list": [ + "Doorbell Ring 1", + "Doorbell Ring 2", + "Doorbell Ring 3", + "Doorbell Ring 4", + "Doorbell Ring 5", + "Doorbell Ring 6", + "Doorbell Ring 7", + "Doorbell Ring 8", + "Doorbell Ring 9", + "Doorbell Ring 10", + "Phone Ring", + "Alarm 1", + "Alarm 2", + "Alarm 3", + "Alarm 4", + "Dripping Tap", + "Alarm 5", + "Connection 1", + "Connection 2" + ] + }, + "get_support_child_device_category": { + "device_category_list": [ + { + "category": "subg.trv" + }, + { + "category": "subg.trigger" + }, + { + "category": "subg.plugswitch" + } + ] + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 3 + } + ], + "extra_info": { + "device_model": "H100", + "device_type": "SMART.TAPOHUB", + "is_klap": false + } + } +} diff --git a/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json b/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json new file mode 100644 index 00000000..cd3a241e --- /dev/null +++ b/kasa/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json @@ -0,0 +1,170 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 5, + "category": "subg.trv", + "child_protection": false, + "current_temp": 22.9, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "frost_protection_on": false, + "fw_ver": "2.4.0 Build 230804 Rel.193040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1713888871, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -7, + "signal_level": 3, + "specs": "EU", + "status": "online", + "target_temp": 23.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-02-05", + "release_note": "Modifications and Bug Fixes:\n1. Optimized the noise issue in some cases.\n2. Fixed some minor bugs.", + "type": 2 + } +} From 7f98acd477fdfa68ce26eacbc733926797689bc0 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 May 2024 20:56:24 +0200 Subject: [PATCH 02/11] Add 'battery_percentage' only when it's available (#906) At least some firmware versions of T110 are known not to report this. --- kasa/smart/modules/battery.py | 39 +++++++++++++++++------------------ 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/battery.py index 6f914bdf..415e47d1 100644 --- a/kasa/smart/modules/battery.py +++ b/kasa/smart/modules/battery.py @@ -2,14 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - class BatterySensor(SmartModule): """Implementation of battery module.""" @@ -17,23 +12,11 @@ class BatterySensor(SmartModule): REQUIRED_COMPONENT = "battery_detect" QUERY_GETTER_NAME = "get_battery_detect_info" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features.""" self._add_feature( Feature( - device, - "battery_level", - "Battery level", - container=self, - attribute_getter="battery", - icon="mdi:battery", - unit="%", - category=Feature.Category.Info, - ) - ) - self._add_feature( - Feature( - device, + self._device, "battery_low", "Battery low", container=self, @@ -44,6 +27,22 @@ class BatterySensor(SmartModule): ) ) + # Some devices, like T110 contact sensor do not report the battery percentage + if "battery_percentage" in self._device.sys_info: + self._add_feature( + Feature( + self._device, + "battery_level", + "Battery level", + container=self, + attribute_getter="battery", + icon="mdi:battery", + unit="%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + @property def battery(self): """Return battery level.""" From 353e84438c4e7a323ca948146271f743d9772b7d Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 7 May 2024 20:58:18 +0200 Subject: [PATCH 03/11] Add support for contact sensor (T110) (#877) Initial support for T110 contact sensor & T110 fixture by courtesy of @ngaertner. --- README.md | 2 +- SUPPORTED.md | 2 + kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/contact.py | 42 ++ kasa/smart/smartchilddevice.py | 1 + kasa/smart/smartdevice.py | 5 +- kasa/smart/smartmodule.py | 18 +- kasa/tests/device_fixtures.py | 2 +- .../smart/child/T110(EU)_1.0_1.8.0.json | 526 ++++++++++++++++++ kasa/tests/smart/modules/test_contact.py | 29 + 10 files changed, 621 insertions(+), 8 deletions(-) create mode 100644 kasa/smart/modules/contact.py create mode 100644 kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json create mode 100644 kasa/tests/smart/modules/test_contact.py diff --git a/README.md b/README.md index 85fc6982..42ecaaa8 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ The following devices have been tested and confirmed as working. If your device - **Bulbs**: L510B, L510E, L530E - **Light Strips**: L900-10, L900-5, L920-5, L930-5 - **Hubs**: H100 -- **Hub-Connected Devices\*\*\***: T300, T310, T315 +- **Hub-Connected Devices\*\*\***: T110, T300, T310, T315 \*   Model requires authentication
diff --git a/SUPPORTED.md b/SUPPORTED.md index 451efe68..f3c505e4 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -214,6 +214,8 @@ All Tapo devices require authentication.
Hub-Connected Devices may work acros ### Hub-Connected Devices +- **T110** + - Hardware: 1.0 (EU) / Firmware: 1.8.0 - **T300** - Hardware: 1.0 (EU) / Firmware: 1.7.0 - **T310** diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index 64722079..b0956b80 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -8,6 +8,7 @@ from .childdevicemodule import ChildDeviceModule from .cloudmodule import CloudModule from .colormodule import ColorModule from .colortemp import ColorTemperatureModule +from .contact import ContactSensor from .devicemodule import DeviceModule from .energymodule import EnergyModule from .fanmodule import FanModule @@ -45,5 +46,6 @@ __all__ = [ "ColorTemperatureModule", "ColorModule", "WaterleakSensor", + "ContactSensor", "FrostProtectionModule", ] diff --git a/kasa/smart/modules/contact.py b/kasa/smart/modules/contact.py new file mode 100644 index 00000000..7932a081 --- /dev/null +++ b/kasa/smart/modules/contact.py @@ -0,0 +1,42 @@ +"""Implementation of contact sensor module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ...feature import Feature +from ..smartmodule import SmartModule + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class ContactSensor(SmartModule): + """Implementation of contact sensor module.""" + + REQUIRED_COMPONENT = None # we depend on availability of key + REQUIRED_KEY_ON_PARENT = "open" + + def __init__(self, device: SmartDevice, module: str): + super().__init__(device, module) + self._add_feature( + Feature( + device, + id="is_open", + name="Open", + container=self, + attribute_getter="is_open", + icon="mdi:door", + category=Feature.Category.Primary, + type=Feature.Type.BinarySensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def is_open(self): + """Return True if the contact sensor is open.""" + return self._device.sys_info["open"] diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py index 7f747b84..d841d2d9 100644 --- a/kasa/smart/smartchilddevice.py +++ b/kasa/smart/smartchilddevice.py @@ -49,6 +49,7 @@ class SmartChildDevice(SmartDevice): """Return child device type.""" child_device_map = { "plug.powerstrip.sub-plug": DeviceType.Plug, + "subg.trigger.contact-sensor": DeviceType.Sensor, "subg.trigger.temp-hmdt-sensor": DeviceType.Sensor, "subg.trigger.water-leak-sensor": DeviceType.Sensor, "kasa.switch.outlet.sub-fan": DeviceType.Fan, diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 89813387..68b08902 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -210,7 +210,10 @@ class SmartDevice(Bulb, Fan, Device): skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES ) or mod.__name__ in child_modules_to_skip: continue - if mod.REQUIRED_COMPONENT in self._components: + if ( + mod.REQUIRED_COMPONENT in self._components + or self.sys_info.get(mod.REQUIRED_KEY_ON_PARENT) is not None + ): _LOGGER.debug( "Found required %s, adding %s to modules.", mod.REQUIRED_COMPONENT, diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py index 9169b752..e78f4393 100644 --- a/kasa/smart/smartmodule.py +++ b/kasa/smart/smartmodule.py @@ -18,8 +18,13 @@ class SmartModule(Module): """Base class for SMART modules.""" NAME: str - REQUIRED_COMPONENT: str + #: Module is initialized, if the given component is available + REQUIRED_COMPONENT: str | None = None + #: Module is initialized, if the given key available in the main sysinfo + REQUIRED_KEY_ON_PARENT: str | None = None + #: Query to execute during the main update cycle QUERY_GETTER_NAME: str + REGISTERED_MODULES: dict[str, type[SmartModule]] = {} def __init__(self, device: SmartDevice, module: str): @@ -27,8 +32,6 @@ class SmartModule(Module): super().__init__(device, module) def __init_subclass__(cls, **kwargs): - assert cls.REQUIRED_COMPONENT is not None # noqa: S101 - name = getattr(cls, "NAME", cls.__name__) _LOGGER.debug("Registering %s" % cls) cls.REGISTERED_MODULES[name] = cls @@ -91,8 +94,13 @@ class SmartModule(Module): @property def supported_version(self) -> int: - """Return version supported by the device.""" - return self._device._components[self.REQUIRED_COMPONENT] + """Return version supported by the device. + + If the module has no required component, this will return -1. + """ + if self.REQUIRED_COMPONENT is not None: + return self._device._components[self.REQUIRED_COMPONENT] + return -1 async def _check_supported(self) -> bool: """Additional check to see if the module is supported by the device. diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 92a86b6f..826465e5 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -109,7 +109,7 @@ DIMMERS = { } HUBS_SMART = {"H100", "KH100"} -SENSORS_SMART = {"T310", "T315", "T300"} +SENSORS_SMART = {"T310", "T315", "T300", "T110"} THERMOSTATS_SMART = {"KE100"} WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} diff --git a/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json new file mode 100644 index 00000000..acf7ae88 --- /dev/null +++ b/kasa/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json @@ -0,0 +1,526 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "trigger_log", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t110", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.8.0 Build 220728 Rel.160024", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1714661626, + "mac": "E4FAC4000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -54, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 30, + "reboot_time": 5, + "status": 4, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "1.9.0 Build 230704 Rel.154531", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-30", + "release_note": "Modifications and Bug Fixes:\n1. Reduced power consumption.\n2. Fixed some minor bugs.", + "type": 2 + }, + "get_temp_humidity_records": { + "local_time": 1714681046, + "past24h_humidity": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_humidity_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "past24h_temp_exception": [ + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000, + -1000 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "close", + "eventId": "8140289c-c66b-bdd6-63b9-542299442299", + "id": 4, + "timestamp": 1714661714 + }, + { + "event": "open", + "eventId": "fb4e1439-2f2c-a5e1-c35a-9e7c0d35a1e3", + "id": 3, + "timestamp": 1714661710 + }, + { + "event": "close", + "eventId": "ddee7733-1180-48ac-56a3-512018048ac5", + "id": 2, + "timestamp": 1714661657 + }, + { + "event": "open", + "eventId": "ab80951f-da38-49f9-21c5-bf025c7b606d", + "id": 1, + "timestamp": 1714661638 + } + ], + "start_id": 4, + "sum": 4 + } +} diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py new file mode 100644 index 00000000..fc337545 --- /dev/null +++ b/kasa/tests/smart/modules/test_contact.py @@ -0,0 +1,29 @@ +import pytest + +from kasa import SmartDevice +from kasa.smart.modules import ContactSensor +from kasa.tests.device_fixtures import parametrize + +contact = parametrize( + "is contact sensor", model_filter="T110", protocol_filter={"SMART.CHILD"} +) + + +@contact +@pytest.mark.parametrize( + "feature, type", + [ + ("is_open", bool), + ], +) +async def test_contact_features(dev: SmartDevice, feature, type): + """Test that features are registered and work as expected.""" + contact = dev.get_module(ContactSensor) + assert contact is not None + + prop = getattr(contact, feature) + assert isinstance(prop, type) + + feat = contact._module_features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) From 1e8e289ac7fa49798534bae4da567fb6ecaf74bf Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 8 May 2024 15:25:22 +0200 Subject: [PATCH 04/11] Move contribution instructions into docs (#901) Moves the instructions away from README.md to keep it simpler, and extend the documentation to be up-to-date and easier to approach. --------- Co-authored-by: Steven B. <51370195+sdb9696@users.noreply.github.com> --- CONTRIBUTING.md | 4 ++ README.md | 36 ++-------------- docs/source/contribute.md | 86 +++++++++++++++++++++++++++++++++++++++ docs/source/index.rst | 1 + 4 files changed, 94 insertions(+), 33 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/source/contribute.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1f400543 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contributing to python-kasa + +All types of contributions are very welcome. +To make the process as straight-forward as possible, we have written [some instructions in our docs](https://python-miio.readthedocs.io/en/latest/contribute.html) to get you started. diff --git a/README.md b/README.md index 42ecaaa8..6c4cfcce 100644 --- a/README.md +++ b/README.md @@ -185,42 +185,12 @@ The device type specific documentation can be found in their separate pages: ## Contributing -Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions. - -### Setting up development environment - -To get started, simply clone this repository and initialize the development environment. -We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute -`poetry install` which will install all necessary packages and create a virtual environment for you. - -### Code-style checks - -We use several tools to automatically check all contributions. The simplest way to verify that everything is formatted properly -before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. -This will make sure that the checks are passing when you do a commit. - -You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests. - -### Running tests - -You can run tests on the library by executing `pytest` in the source directory. -This will run the tests against contributed example responses, but you can also execute the tests against a real device: -``` -$ pytest --ip
-``` -Note that this will perform state changes on the device. - -### Analyzing network captures - -The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. -After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) -or the `parse_pcap.py` script contained inside the `devtools` directory. -Note, that this works currently only on kasa-branded devices which use port 9999 for communications. - +Contributions are very welcome! The easiest way to contribute is by [creating a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) for the automated test suite if your device hardware and firmware version is not currently listed as supported. +Please refer to [our contributing guidelines](https://python-kasa.readthedocs.io/en/latest/contribute.html). ## Supported devices -The following devices have been tested and confirmed as working. If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one). +The following devices have been tested and confirmed as working. If your device is unlisted but working, please consider [contributing a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files). diff --git a/docs/source/contribute.md b/docs/source/contribute.md new file mode 100644 index 00000000..67291eba --- /dev/null +++ b/docs/source/contribute.md @@ -0,0 +1,86 @@ +# Contributing + +You probably arrived to this page as you are interested in contributing to python-kasa in some form? +All types of contributions are very welcome, so thank you! +This page aims to help you to get started. + +```{contents} Contents + :local: +``` + +## Setting up the development environment + +To get started, simply clone this repository and initialize the development environment. +We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute +`poetry install` which will install all necessary packages and create a virtual environment for you. + +``` +$ git clone https://github.com/python-kasa/python-kasa.git +$ cd python-kasa +$ poetry install +``` + +## Code-style checks + +We use several tools to automatically check all contributions as part of our CI pipeline. +The simplest way to verify that everything is formatted properly +before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. +This will make sure that the checks are passing when you do a commit. + +```{note} +You can also execute the pre-commit hooks on all files by executing `pre-commit run -a` +``` + +## Running tests + +You can run tests on the library by executing `pytest` in the source directory: + +``` +$ poetry run pytest kasa +``` + +This will run the tests against the contributed example responses. + +```{note} +You can also execute the tests against a real device using `pytest --ip
`. +Note that this will perform state changes on the device. +``` + +## Analyzing network captures + +The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. +After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) +or the `parse_pcap.py` script contained inside the `devtools` directory. +Note, that this works currently only on kasa-branded devices which use port 9999 for communications. + +## Contributing fixture files + +One of the easiest ways to contribute is by creating a fixture file and uploading it for us. +These files will help us to improve the library and run tests against devices that we have no access to. + +This library is tested against responses from real devices ("fixture files"). +These files contain responses for selected, known device commands and are stored [in our test suite](https://github.com/python-kasa/python-kasa/tree/master/kasa/tests/fixtures). + +You can generate these files by using the `dump_devinfo.py` script. +Note, that this script should be run inside the main source directory so that the generated files are stored in the correct directories. +The easiest way to do that is by doing: + +``` +$ git clone https://github.com/python-kasa/python-kasa.git +$ cd python-kasa +$ poetry install +$ poetry shell +$ python -m devtools.dump_devinfo --username --password --host 192.168.1.123 +``` + +```{note} +You can also execute the script against a network by using `--target`: `python -m devtools.dump_devinfo --target network 192.168.1.255` +``` + +The script will run queries against the device, and prompt at the end if you want to save the results. +If you choose to do so, it will save the fixture files directly in their correct place to make it easy to create a pull request. + +```{note} +When adding new fixture files, you should run `pre-commit run -a` to re-generate the list of supported devices. +You may need to adjust `device_fixtures.py` to add a new model into the correct device categories. Verify that test pass by executing `poetry run pytest kasa`. +``` diff --git a/docs/source/index.rst b/docs/source/index.rst index 9dc648a9..f5baf389 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -10,6 +10,7 @@ discover smartdevice design + contribute smartbulb smartplug smartdimmer From 7d4dc4c710d08d415ddb70cae4a7206784a66222 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 9 May 2024 01:43:07 +0200 Subject: [PATCH 05/11] Improve smartdevice update module (#791) * Expose current and latest firmware as features * Provide API to get information about available firmware updates (e.g., changelog, release date etc.) * Implement updating the firmware --- kasa/smart/modules/firmware.py | 116 ++++++++++++++++++++-- kasa/tests/fakeprotocol_smart.py | 2 +- kasa/tests/smart/modules/test_firmware.py | 108 ++++++++++++++++++++ 3 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 kasa/tests/smart/modules/test_firmware.py diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py index 626add0f..430515e4 100644 --- a/kasa/smart/modules/firmware.py +++ b/kasa/smart/modules/firmware.py @@ -2,9 +2,14 @@ from __future__ import annotations +import asyncio +import logging from datetime import date -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional +# When support for cpython older than 3.11 is dropped +# async_timeout can be replaced with asyncio.timeout +from async_timeout import timeout as asyncio_timeout from pydantic.v1 import BaseModel, Field, validator from ...exceptions import SmartErrorCode @@ -15,11 +20,27 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice +_LOGGER = logging.getLogger(__name__) + + +class DownloadState(BaseModel): + """Download state.""" + + # Example: + # {'status': 0, 'download_progress': 0, 'reboot_time': 5, + # 'upgrade_time': 5, 'auto_upgrade': False} + status: int + progress: int = Field(alias="download_progress") + reboot_time: int + upgrade_time: int + auto_upgrade: bool + + class UpdateInfo(BaseModel): """Update info status object.""" status: int = Field(alias="type") - fw_ver: Optional[str] = None # noqa: UP007 + version: Optional[str] = Field(alias="fw_ver", default=None) # noqa: UP007 release_date: Optional[date] = None # noqa: UP007 release_notes: Optional[str] = Field(alias="release_note", default=None) # noqa: UP007 fw_size: Optional[int] = None # noqa: UP007 @@ -71,6 +92,26 @@ class Firmware(SmartModule): category=Feature.Category.Info, ) ) + self._add_feature( + Feature( + device, + id="current_firmware_version", + name="Current firmware version", + container=self, + attribute_getter="current_firmware", + category=Feature.Category.Debug, + ) + ) + self._add_feature( + Feature( + device, + id="available_firmware_version", + name="Available firmware version", + container=self, + attribute_getter="latest_firmware", + category=Feature.Category.Debug, + ) + ) def query(self) -> dict: """Query to execute during the update cycle.""" @@ -80,7 +121,17 @@ class Firmware(SmartModule): return req @property - def latest_firmware(self): + def current_firmware(self) -> str: + """Return the current firmware version.""" + return self._device.hw_info["sw_ver"] + + @property + def latest_firmware(self) -> str: + """Return the latest firmware version.""" + return self.firmware_update_info.version + + @property + def firmware_update_info(self): """Return latest firmware information.""" fw = self.data.get("get_latest_fw") or self.data if not self._device.is_cloud_connected or isinstance(fw, SmartErrorCode): @@ -94,15 +145,62 @@ class Firmware(SmartModule): """Return True if update is available.""" if not self._device.is_cloud_connected: return None - return self.latest_firmware.update_available + return self.firmware_update_info.update_available - async def get_update_state(self): + async def get_update_state(self) -> DownloadState: """Return update state.""" - return await self.call("get_fw_download_state") + resp = await self.call("get_fw_download_state") + state = resp["get_fw_download_state"] + return DownloadState(**state) - async def update(self): + async def update( + self, progress_cb: Callable[[DownloadState], Coroutine] | None = None + ): """Update the device firmware.""" - return await self.call("fw_download") + current_fw = self.current_firmware + _LOGGER.info( + "Going to upgrade from %s to %s", + current_fw, + self.firmware_update_info.version, + ) + await self.call("fw_download") + + # TODO: read timeout from get_auto_update_info or from get_fw_download_state? + async with asyncio_timeout(60 * 5): + while True: + await asyncio.sleep(0.5) + try: + state = await self.get_update_state() + except Exception as ex: + _LOGGER.warning( + "Got exception, maybe the device is rebooting? %s", ex + ) + continue + + _LOGGER.debug("Update state: %s" % state) + if progress_cb is not None: + asyncio.create_task(progress_cb(state)) + + if state.status == 0: + _LOGGER.info( + "Update idle, hopefully updated to %s", + self.firmware_update_info.version, + ) + break + elif state.status == 2: + _LOGGER.info("Downloading firmware, progress: %s", state.progress) + elif state.status == 3: + upgrade_sleep = state.upgrade_time + _LOGGER.info( + "Flashing firmware, sleeping for %s before checking status", + upgrade_sleep, + ) + await asyncio.sleep(upgrade_sleep) + elif state.status < 0: + _LOGGER.error("Got error: %s", state.status) + break + else: + _LOGGER.warning("Unhandled state code: %s", state) @property def auto_update_enabled(self): @@ -115,4 +213,4 @@ class Firmware(SmartModule): async def set_auto_update_enabled(self, enabled: bool): """Change autoupdate setting.""" data = {**self.data["get_auto_update_info"], "enable": enabled} - await self.call("set_auto_update_info", data) # {"enable": enabled}) + await self.call("set_auto_update_info", data) diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index ae1a7ad6..5ca4a8ae 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -234,7 +234,7 @@ class FakeSmartTransport(BaseTransport): pytest.fixtures_missing_methods[self.fixture_name] = set() pytest.fixtures_missing_methods[self.fixture_name].add(method) return retval - elif method == "set_qs_info": + elif method in ["set_qs_info", "fw_download"]: return {"error_code": 0} elif method == "set_dynamic_light_effect_rule_enable": self._set_light_effect(info, params) diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py new file mode 100644 index 00000000..d0df87ca --- /dev/null +++ b/kasa/tests/smart/modules/test_firmware.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import asyncio +import logging + +import pytest +from pytest_mock import MockerFixture + +from kasa.smart import SmartDevice +from kasa.smart.modules import Firmware +from kasa.smart.modules.firmware import DownloadState +from kasa.tests.device_fixtures import parametrize + +firmware = parametrize( + "has firmware", component_filter="firmware", protocol_filter={"SMART"} +) + + +@firmware +@pytest.mark.parametrize( + "feature, prop_name, type, required_version", + [ + ("auto_update_enabled", "auto_update_enabled", bool, 2), + ("update_available", "update_available", bool, 1), + ("update_available", "update_available", bool, 1), + ("current_firmware_version", "current_firmware", str, 1), + ("available_firmware_version", "latest_firmware", str, 1), + ], +) +async def test_firmware_features( + dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture +): + """Test light effect.""" + fw = dev.get_module(Firmware) + assert fw + + if not dev.is_cloud_connected: + pytest.skip("Device is not cloud connected, skipping test") + + if fw.supported_version < required_version: + pytest.skip("Feature %s requires newer version" % feature) + + prop = getattr(fw, prop_name) + assert isinstance(prop, type) + + feat = fw._module_features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@firmware +async def test_update_available_without_cloud(dev: SmartDevice): + """Test that update_available returns None when disconnected.""" + fw = dev.get_module(Firmware) + assert fw + + if dev.is_cloud_connected: + assert isinstance(fw.update_available, bool) + else: + assert fw.update_available is None + + +@firmware +async def test_firmware_update( + dev: SmartDevice, mocker: MockerFixture, caplog: pytest.LogCaptureFixture +): + """Test updating firmware.""" + caplog.set_level(logging.INFO) + + fw = dev.get_module(Firmware) + assert fw + + upgrade_time = 5 + extras = {"reboot_time": 5, "upgrade_time": upgrade_time, "auto_upgrade": False} + update_states = [ + # Unknown 1 + DownloadState(status=1, download_progress=0, **extras), + # Downloading + DownloadState(status=2, download_progress=10, **extras), + DownloadState(status=2, download_progress=100, **extras), + # Flashing + DownloadState(status=3, download_progress=100, **extras), + DownloadState(status=3, download_progress=100, **extras), + # Done + DownloadState(status=0, download_progress=100, **extras), + ] + + asyncio_sleep = asyncio.sleep + sleep = mocker.patch("asyncio.sleep") + mocker.patch.object(fw, "get_update_state", side_effect=update_states) + + cb_mock = mocker.AsyncMock() + + await fw.update(progress_cb=cb_mock) + + # This is necessary to allow the eventloop to process the created tasks + await asyncio_sleep(0) + + assert "Unhandled state code" in caplog.text + assert "Downloading firmware, progress: 10" in caplog.text + assert "Flashing firmware, sleeping" in caplog.text + assert "Update idle" in caplog.text + + for state in update_states: + cb_mock.assert_any_await(state) + + # sleep based on the upgrade_time + sleep.assert_any_call(upgrade_time) From 9473d97ad2b5cb8645df1c06c3dbb477817fee9a Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Fri, 10 May 2024 19:29:28 +0100 Subject: [PATCH 06/11] Create common interfaces for remaining device types (#895) Introduce common module interfaces across smart and iot devices and provide better typing implementation for getting modules to support this. --- .pre-commit-config.yaml | 5 + devtools/create_module_fixtures.py | 2 +- kasa/__init__.py | 6 +- kasa/bulb.py | 5 - kasa/device.py | 21 ++-- kasa/interfaces/led.py | 38 ++++++++ kasa/interfaces/lighteffect.py | 80 +++++++++++++++ kasa/{ => iot}/effects.py | 0 kasa/iot/iotdevice.py | 53 ++++------ kasa/iot/iotlightstrip.py | 30 +++--- kasa/iot/iotmodule.py | 10 +- kasa/iot/iotplug.py | 27 +----- kasa/iot/iotstrip.py | 1 - kasa/iot/modules/__init__.py | 2 + kasa/iot/modules/ledmodule.py | 32 ++++++ kasa/iot/modules/lighteffectmodule.py | 97 +++++++++++++++++++ kasa/module.py | 62 +++++++++++- kasa/modulemapping.py | 25 +++++ kasa/modulemapping.pyi | 96 ++++++++++++++++++ kasa/plug.py | 12 --- kasa/smart/modules/ledmodule.py | 27 +----- kasa/smart/modules/lighteffectmodule.py | 45 +++++---- kasa/smart/smartdevice.py | 45 ++++----- kasa/tests/fakeprotocol_smart.py | 12 ++- kasa/tests/smart/features/test_brightness.py | 2 +- kasa/tests/smart/modules/test_contact.py | 5 +- kasa/tests/smart/modules/test_fan.py | 8 +- kasa/tests/smart/modules/test_firmware.py | 8 +- kasa/tests/smart/modules/test_light_effect.py | 7 +- kasa/tests/test_common_modules.py | 95 ++++++++++++++++++ kasa/tests/test_iotdevice.py | 13 ++- kasa/tests/test_lightstrip.py | 3 +- kasa/tests/test_smartdevice.py | 19 ++-- 33 files changed, 673 insertions(+), 220 deletions(-) create mode 100644 kasa/interfaces/led.py create mode 100644 kasa/interfaces/lighteffect.py rename kasa/{ => iot}/effects.py (100%) create mode 100644 kasa/iot/modules/ledmodule.py create mode 100644 kasa/iot/modules/lighteffectmodule.py create mode 100644 kasa/modulemapping.py create mode 100644 kasa/modulemapping.pyi delete mode 100644 kasa/plug.py create mode 100644 kasa/tests/test_common_modules.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c0438d9..c274bb97 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,6 +21,11 @@ repos: hooks: - id: mypy additional_dependencies: [types-click] + exclude: | + (?x)^( + kasa/modulemapping\.py| + )$ + - repo: https://github.com/PyCQA/doc8 rev: 'v1.1.1' diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index 8372bfff..ed881a88 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -19,7 +19,7 @@ app = typer.Typer() def create_fixtures(dev: IotDevice, outputdir: Path): """Iterate over supported modules and create version-specific fixture files.""" for name, module in dev.modules.items(): - module_dir = outputdir / name + module_dir = outputdir / str(name) if not module_dir.exists(): module_dir.mkdir(exist_ok=True, parents=True) diff --git a/kasa/__init__.py b/kasa/__init__.py index 62d54502..e9f64c70 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -16,7 +16,7 @@ from importlib.metadata import version from typing import TYPE_CHECKING from warnings import warn -from kasa.bulb import Bulb +from kasa.bulb import Bulb, BulbPreset from kasa.credentials import Credentials from kasa.device import Device from kasa.device_type import DeviceType @@ -36,12 +36,11 @@ from kasa.exceptions import ( UnsupportedDeviceError, ) from kasa.feature import Feature -from kasa.iot.iotbulb import BulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 ) -from kasa.plug import Plug +from kasa.module import Module from kasa.protocol import BaseProtocol from kasa.smartprotocol import SmartProtocol @@ -62,6 +61,7 @@ __all__ = [ "Device", "Bulb", "Plug", + "Module", "KasaException", "AuthenticationError", "DeviceError", diff --git a/kasa/bulb.py b/kasa/bulb.py index 01065dc0..52a722d9 100644 --- a/kasa/bulb.py +++ b/kasa/bulb.py @@ -54,11 +54,6 @@ class Bulb(Device, ABC): def is_color(self) -> bool: """Whether the bulb supports color changes.""" - @property - @abstractmethod - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - @property @abstractmethod def is_variable_color_temp(self) -> bool: diff --git a/kasa/device.py b/kasa/device.py index ea358a8d..8150352d 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -6,7 +6,7 @@ import logging from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime -from typing import Any, Mapping, Sequence, overload +from typing import TYPE_CHECKING, Any, Mapping, Sequence from .credentials import Credentials from .device_type import DeviceType @@ -15,10 +15,13 @@ from .emeterstatus import EmeterStatus from .exceptions import KasaException from .feature import Feature from .iotprotocol import IotProtocol -from .module import Module, ModuleT +from .module import Module from .protocol import BaseProtocol from .xortransport import XorTransport +if TYPE_CHECKING: + from .modulemapping import ModuleMapping + @dataclass class WifiNetwork: @@ -113,21 +116,9 @@ class Device(ABC): @property @abstractmethod - def modules(self) -> Mapping[str, Module]: + def modules(self) -> ModuleMapping[Module]: """Return the device modules.""" - @overload - @abstractmethod - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - @abstractmethod - def get_module(self, module_type: str) -> Module | None: ... - - @abstractmethod - def get_module(self, module_type: type[ModuleT] | str) -> ModuleT | Module | None: - """Return the module from the device modules or None if not present.""" - @property @abstractmethod def is_on(self) -> bool: diff --git a/kasa/interfaces/led.py b/kasa/interfaces/led.py new file mode 100644 index 00000000..2ddba00c --- /dev/null +++ b/kasa/interfaces/led.py @@ -0,0 +1,38 @@ +"""Module for base light effect module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..feature import Feature +from ..module import Module + + +class Led(Module, ABC): + """Base interface to represent a LED module.""" + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device=device, + container=self, + name="LED", + id="led", + icon="mdi:led", + attribute_getter="led", + attribute_setter="set_led", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + @property + @abstractmethod + def led(self) -> bool: + """Return current led status.""" + + @abstractmethod + async def set_led(self, enable: bool) -> None: + """Set led.""" diff --git a/kasa/interfaces/lighteffect.py b/kasa/interfaces/lighteffect.py new file mode 100644 index 00000000..0eb11b5b --- /dev/null +++ b/kasa/interfaces/lighteffect.py @@ -0,0 +1,80 @@ +"""Module for base light effect module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..feature import Feature +from ..module import Module + + +class LightEffect(Module, ABC): + """Interface to represent a light effect module.""" + + LIGHT_EFFECTS_OFF = "Off" + + def _initialize_features(self): + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + id="light_effect", + name="Light effect", + container=self, + attribute_getter="effect", + attribute_setter="set_effect", + category=Feature.Category.Primary, + type=Feature.Type.Choice, + choices_getter="effect_list", + ) + ) + + @property + @abstractmethod + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + + @property + @abstractmethod + def effect(self) -> str: + """Return effect state or name.""" + + @property + @abstractmethod + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + + @abstractmethod + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ diff --git a/kasa/effects.py b/kasa/iot/effects.py similarity index 100% rename from kasa/effects.py rename to kasa/iot/effects.py diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 29ba3155..762fc06c 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -19,14 +19,15 @@ import functools import inspect import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast, overload +from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..device import Device, WifiNetwork from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import KasaException from ..feature import Feature -from ..module import ModuleT +from ..module import Module +from ..modulemapping import ModuleMapping, ModuleName from ..protocol import BaseProtocol from .iotmodule import IotModule from .modules import Emeter, Time @@ -190,7 +191,7 @@ class IotDevice(Device): self._supported_modules: dict[str, IotModule] | None = None self._legacy_features: set[str] = set() self._children: Mapping[str, IotDevice] = {} - self._modules: dict[str, IotModule] = {} + self._modules: dict[str | ModuleName[Module], IotModule] = {} @property def children(self) -> Sequence[IotDevice]: @@ -198,38 +199,20 @@ class IotDevice(Device): return list(self._children.values()) @property - def modules(self) -> dict[str, IotModule]: + def modules(self) -> ModuleMapping[IotModule]: """Return the device modules.""" + if TYPE_CHECKING: + return cast(ModuleMapping[IotModule], self._modules) return self._modules - @overload - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - def get_module(self, module_type: str) -> IotModule | None: ... - - def get_module( - self, module_type: type[ModuleT] | str - ) -> ModuleT | IotModule | None: - """Return the module from the device modules or None if not present.""" - if isinstance(module_type, str): - module_name = module_type.lower() - elif issubclass(module_type, IotModule): - module_name = module_type.__name__.lower() - else: - return None - if module_name in self.modules: - return self.modules[module_name] - return None - - def add_module(self, name: str, module: IotModule): + def add_module(self, name: str | ModuleName[Module], module: IotModule): """Register a module.""" if name in self.modules: _LOGGER.debug("Module %s already registered, ignoring..." % name) return _LOGGER.debug("Adding module %s", module) - self.modules[name] = module + self._modules[name] = module def _create_request( self, target: str, cmd: str, arg: dict | None = None, child_ids=None @@ -291,11 +274,11 @@ class IotDevice(Device): @property # type: ignore @requires_update - def supported_modules(self) -> list[str]: + def supported_modules(self) -> list[str | ModuleName[Module]]: """Return a set of modules supported by the device.""" # TODO: this should rather be called `features`, but we don't want to break # the API now. Maybe just deprecate it and point the users to use this? - return list(self.modules.keys()) + return list(self._modules.keys()) @property # type: ignore @requires_update @@ -324,10 +307,11 @@ class IotDevice(Device): self._last_update = response self._set_sys_info(response["system"]["get_sysinfo"]) + await self._modular_update(req) + if not self._features: await self._initialize_features() - await self._modular_update(req) self._set_sys_info(self._last_update["system"]["get_sysinfo"]) async def _initialize_features(self): @@ -352,6 +336,11 @@ class IotDevice(Device): ) ) + for module in self._modules.values(): + module._initialize_features() + for module_feat in module._module_features.values(): + self._add_feature(module_feat) + async def _modular_update(self, req: dict) -> None: """Execute an update query.""" if self.has_emeter: @@ -364,17 +353,15 @@ class IotDevice(Device): # making separate handling for this unnecessary if self._supported_modules is None: supported = {} - for module in self.modules.values(): + for module in self._modules.values(): if module.is_supported: supported[module._module] = module - for module_feat in module._module_features.values(): - self._add_feature(module_feat) self._supported_modules = supported request_list = [] est_response_size = 1024 if "system" in req else 0 - for module in self.modules.values(): + for module in self._modules.values(): if not module.is_supported: _LOGGER.debug("Module %s not supported, skipping" % module) continue diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 57b3282f..a120be7a 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -4,10 +4,12 @@ from __future__ import annotations from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..module import Module from ..protocol import BaseProtocol +from .effects import EFFECT_NAMES_V1 from .iotbulb import IotBulb from .iotdevice import KasaException, requires_update +from .modules.lighteffectmodule import LightEffectModule class IotLightStrip(IotBulb): @@ -54,6 +56,10 @@ class IotLightStrip(IotBulb): ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip + self.add_module( + Module.LightEffect, + LightEffectModule(self, "smartlife.iot.lighting_effect"), + ) @property # type: ignore @requires_update @@ -73,6 +79,8 @@ class IotLightStrip(IotBulb): 'id': '', 'name': ''} """ + # LightEffectModule returns the current effect name + # so return the dict here for backwards compatibility return self.sys_info["lighting_effect_state"] @property # type: ignore @@ -83,6 +91,8 @@ class IotLightStrip(IotBulb): Example: ['Aurora', 'Bubbling Cauldron', ...] """ + # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value + # so return the original effect names here for backwards compatibility return EFFECT_NAMES_V1 if self.has_effects else None @requires_update @@ -105,15 +115,9 @@ class IotLightStrip(IotBulb): :param int brightness: The wanted brightness :param int transition: The wanted transition time """ - if effect not in EFFECT_MAPPING_V1: - raise KasaException(f"The effect {effect} is not a built in effect.") - effect_dict = EFFECT_MAPPING_V1[effect] - if brightness is not None: - effect_dict["brightness"] = brightness - if transition is not None: - effect_dict["transition"] = transition - - await self.set_custom_effect(effect_dict) + await self.modules[Module.LightEffect].set_effect( + effect, brightness=brightness, transition=transition + ) @requires_update async def set_custom_effect( @@ -126,8 +130,4 @@ class IotLightStrip(IotBulb): """ if not self.has_effects: raise KasaException("Bulb does not support effects.") - await self._query_helper( - "smartlife.iot.lighting_effect", - "set_lighting_effect", - effect_dict, - ) + await self.modules[Module.LightEffect].set_custom_effect(effect_dict) diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py index d8fb4812..ca0c3adb 100644 --- a/kasa/iot/iotmodule.py +++ b/kasa/iot/iotmodule.py @@ -43,13 +43,19 @@ class IotModule(Module): @property def data(self): """Return the module specific raw data from the last update.""" - if self._module not in self._device._last_update: + dev = self._device + q = self.query() + + if not q: + return dev.sys_info + + if self._module not in dev._last_update: raise KasaException( f"You need to call update() prior accessing module data" f" for '{self._module}'" ) - return self._device._last_update[self._module] + return dev._last_update[self._module] @property def is_supported(self) -> bool: diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index dadb38f2..22238c7a 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -6,10 +6,10 @@ import logging from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update -from .modules import Antitheft, Cloud, Schedule, Time, Usage +from .modules import Antitheft, Cloud, LedModule, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -58,21 +58,7 @@ class IotPlug(IotDevice): self.add_module("antitheft", Antitheft(self, "anti_theft")) self.add_module("time", Time(self, "time")) self.add_module("cloud", Cloud(self, "cnCloud")) - - async def _initialize_features(self): - await super()._initialize_features() - - self._add_feature( - Feature( - device=self, - id="led", - name="LED", - icon="mdi:led-{state}", - attribute_getter="led", - attribute_setter="set_led", - type=Feature.Type.Switch, - ) - ) + self.add_module(Module.Led, LedModule(self, "system")) @property # type: ignore @requires_update @@ -93,14 +79,11 @@ class IotPlug(IotDevice): @requires_update def led(self) -> bool: """Return the state of the led.""" - sys_info = self.sys_info - return bool(1 - sys_info["led_off"]) + return self.modules[Module.Led].led async def set_led(self, state: bool): """Set the state of the led (night mode).""" - return await self._query_helper( - "system", "set_led_off", {"off": int(not state)} - ) + return await self.modules[Module.Led].set_led(state) class IotWallSwitch(IotPlug): diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 9e99a074..ab14abb0 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -253,7 +253,6 @@ class IotStripPlug(IotPlug): self._last_update = parent._last_update self._set_sys_info(parent.sys_info) self._device_type = DeviceType.StripSocket - self._modules = {} self.protocol = parent.protocol # Must use the same connection as the parent self.add_module("time", Time(self, "time")) diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index 41e03bbd..f061e607 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -5,6 +5,7 @@ from .antitheft import Antitheft from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter +from .ledmodule import LedModule from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -17,6 +18,7 @@ __all__ = [ "Cloud", "Countdown", "Emeter", + "LedModule", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/ledmodule.py b/kasa/iot/modules/ledmodule.py new file mode 100644 index 00000000..6b3c6194 --- /dev/null +++ b/kasa/iot/modules/ledmodule.py @@ -0,0 +1,32 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...interfaces.led import Led +from ..iotmodule import IotModule + + +class LedModule(IotModule, Led): + """Implementation of led controls.""" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def mode(self): + """LED mode setting. + + "always", "never" + """ + return "always" if self.led else "never" + + @property + def led(self) -> bool: + """Return the state of the led.""" + sys_info = self.data + return bool(1 - sys_info["led_off"]) + + async def set_led(self, state: bool): + """Set the state of the led (night mode).""" + return await self.call("set_led_off", {"off": int(not state)}) diff --git a/kasa/iot/modules/lighteffectmodule.py b/kasa/iot/modules/lighteffectmodule.py new file mode 100644 index 00000000..c53de192 --- /dev/null +++ b/kasa/iot/modules/lighteffectmodule.py @@ -0,0 +1,97 @@ +"""Module for light effects.""" + +from __future__ import annotations + +from ...interfaces.lighteffect import LightEffect +from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..iotmodule import IotModule + + +class LightEffectModule(IotModule, LightEffect): + """Implementation of dynamic light effects.""" + + @property + def effect(self) -> str: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + if ( + (state := self.data.get("lighting_effect_state")) + and state.get("enable") + and (name := state.get("name")) + and name in EFFECT_NAMES_V1 + ): + return name + return self.LIGHT_EFFECTS_OFF + + @property + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + effect_list = [self.LIGHT_EFFECTS_OFF] + effect_list.extend(EFFECT_NAMES_V1) + return effect_list + + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> None: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + if effect == self.LIGHT_EFFECTS_OFF: + effect_dict = dict(self.data["lighting_effect_state"]) + effect_dict["enable"] = 0 + elif effect not in EFFECT_MAPPING_V1: + raise ValueError(f"The effect {effect} is not a built in effect.") + else: + effect_dict = EFFECT_MAPPING_V1[effect] + if brightness is not None: + effect_dict["brightness"] = brightness + if transition is not None: + effect_dict["transition"] = transition + + await self.set_custom_effect(effect_dict) + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + return await self.call( + "set_lighting_effect", + effect_dict, + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return True + + def query(self): + """Return the base query.""" + return {} diff --git a/kasa/module.py b/kasa/module.py index 3da0c1ad..b65f0499 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -6,14 +6,20 @@ import logging from abc import ABC, abstractmethod from typing import ( TYPE_CHECKING, + Final, TypeVar, ) from .exceptions import KasaException from .feature import Feature +from .modulemapping import ModuleName if TYPE_CHECKING: - from .device import Device + from .device import Device as DeviceType # avoid name clash with Device module + from .interfaces.led import Led + from .interfaces.lighteffect import LightEffect + from .iot import modules as iot + from .smart import modules as smart _LOGGER = logging.getLogger(__name__) @@ -27,7 +33,59 @@ class Module(ABC): executed during the regular update cycle. """ - def __init__(self, device: Device, module: str): + # Common Modules + LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffectModule") + Led: Final[ModuleName[Led]] = ModuleName("LedModule") + + # IOT only Modules + IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") + IotAntitheft: Final[ModuleName[iot.Antitheft]] = ModuleName("anti_theft") + IotCountdown: Final[ModuleName[iot.Countdown]] = ModuleName("countdown") + IotEmeter: Final[ModuleName[iot.Emeter]] = ModuleName("emeter") + IotMotion: Final[ModuleName[iot.Motion]] = ModuleName("motion") + IotSchedule: Final[ModuleName[iot.Schedule]] = ModuleName("schedule") + IotUsage: Final[ModuleName[iot.Usage]] = ModuleName("usage") + IotCloud: Final[ModuleName[iot.Cloud]] = ModuleName("cloud") + IotTime: Final[ModuleName[iot.Time]] = ModuleName("time") + + # SMART only Modules + Alarm: Final[ModuleName[smart.AlarmModule]] = ModuleName("AlarmModule") + AutoOff: Final[ModuleName[smart.AutoOffModule]] = ModuleName("AutoOffModule") + BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") + Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") + ChildDevice: Final[ModuleName[smart.ChildDeviceModule]] = ModuleName( + "ChildDeviceModule" + ) + Cloud: Final[ModuleName[smart.CloudModule]] = ModuleName("CloudModule") + Color: Final[ModuleName[smart.ColorModule]] = ModuleName("ColorModule") + ColorTemp: Final[ModuleName[smart.ColorTemperatureModule]] = ModuleName( + "ColorTemperatureModule" + ) + ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor") + Device: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") + Energy: Final[ModuleName[smart.EnergyModule]] = ModuleName("EnergyModule") + Fan: Final[ModuleName[smart.FanModule]] = ModuleName("FanModule") + Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware") + FrostProtection: Final[ModuleName[smart.FrostProtectionModule]] = ModuleName( + "FrostProtectionModule" + ) + Humidity: Final[ModuleName[smart.HumiditySensor]] = ModuleName("HumiditySensor") + LightTransition: Final[ModuleName[smart.LightTransitionModule]] = ModuleName( + "LightTransitionModule" + ) + Report: Final[ModuleName[smart.ReportModule]] = ModuleName("ReportModule") + Temperature: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( + "TemperatureSensor" + ) + TemperatureSensor: Final[ModuleName[smart.TemperatureControl]] = ModuleName( + "TemperatureControl" + ) + Time: Final[ModuleName[smart.TimeModule]] = ModuleName("TimeModule") + WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName( + "WaterleakSensor" + ) + + def __init__(self, device: DeviceType, module: str): self._device = device self._module = module self._module_features: dict[str, Feature] = {} diff --git a/kasa/modulemapping.py b/kasa/modulemapping.py new file mode 100644 index 00000000..06ba8619 --- /dev/null +++ b/kasa/modulemapping.py @@ -0,0 +1,25 @@ +"""Module for Implementation for ModuleMapping and ModuleName types. + +Custom dict for getting typed modules from the module dict. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from .module import Module + +_ModuleT = TypeVar("_ModuleT", bound="Module") + + +class ModuleName(str, Generic[_ModuleT]): + """Generic Module name type. + + At runtime this is a generic subclass of str. + """ + + __slots__ = () + + +ModuleMapping = dict diff --git a/kasa/modulemapping.pyi b/kasa/modulemapping.pyi new file mode 100644 index 00000000..8d110d39 --- /dev/null +++ b/kasa/modulemapping.pyi @@ -0,0 +1,96 @@ +"""Typing stub file for ModuleMapping.""" + +from abc import ABCMeta +from collections.abc import Mapping +from typing import Generic, TypeVar, overload + +from .module import Module + +__all__ = [ + "ModuleMapping", + "ModuleName", +] + +_ModuleT = TypeVar("_ModuleT", bound=Module, covariant=True) +_ModuleBaseT = TypeVar("_ModuleBaseT", bound=Module, covariant=True) + +class ModuleName(Generic[_ModuleT]): + """Class for typed Module names. At runtime delegated to str.""" + + def __init__(self, value: str, /) -> None: ... + def __len__(self) -> int: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __getitem__(self, index: int) -> str: ... + +class ModuleMapping( + Mapping[ModuleName[_ModuleBaseT] | str, _ModuleBaseT], metaclass=ABCMeta +): + """Custom dict type to provide better value type hints for Module key types.""" + + @overload + def __getitem__(self, key: ModuleName[_ModuleT], /) -> _ModuleT: ... + @overload + def __getitem__(self, key: str, /) -> _ModuleBaseT: ... + @overload + def __getitem__( + self, key: ModuleName[_ModuleT] | str, / + ) -> _ModuleT | _ModuleBaseT: ... + @overload # type: ignore[override] + def get(self, key: ModuleName[_ModuleT], /) -> _ModuleT | None: ... + @overload + def get(self, key: str, /) -> _ModuleBaseT | None: ... + @overload + def get( + self, key: ModuleName[_ModuleT] | str, / + ) -> _ModuleT | _ModuleBaseT | None: ... + +def _test_module_mapping_typing() -> None: + """Test ModuleMapping overloads work as intended. + + This is tested during the mypy run and needs to be in this file. + """ + from typing import Any, NewType, cast + + from typing_extensions import assert_type + + from .iot.iotmodule import IotModule + from .module import Module + from .smart.smartmodule import SmartModule + + NewCommonModule = NewType("NewCommonModule", Module) + NewIotModule = NewType("NewIotModule", IotModule) + NewSmartModule = NewType("NewSmartModule", SmartModule) + NotModule = NewType("NotModule", list) + + NEW_COMMON_MODULE: ModuleName[NewCommonModule] = ModuleName("NewCommonModule") + NEW_IOT_MODULE: ModuleName[NewIotModule] = ModuleName("NewIotModule") + NEW_SMART_MODULE: ModuleName[NewSmartModule] = ModuleName("NewSmartModule") + + # TODO Enable --warn-unused-ignores + NOT_MODULE: ModuleName[NotModule] = ModuleName("NotModule") # type: ignore[type-var] # noqa: F841 + NOT_MODULE_2 = ModuleName[NotModule]("NotModule2") # type: ignore[type-var] # noqa: F841 + + device_modules: ModuleMapping[Module] = cast(ModuleMapping[Module], {}) + assert_type(device_modules[NEW_COMMON_MODULE], NewCommonModule) + assert_type(device_modules[NEW_IOT_MODULE], NewIotModule) + assert_type(device_modules[NEW_SMART_MODULE], NewSmartModule) + assert_type(device_modules["foobar"], Module) + assert_type(device_modules[3], Any) # type: ignore[call-overload] + + assert_type(device_modules.get(NEW_COMMON_MODULE), NewCommonModule | None) + assert_type(device_modules.get(NEW_IOT_MODULE), NewIotModule | None) + assert_type(device_modules.get(NEW_SMART_MODULE), NewSmartModule | None) + assert_type(device_modules.get(NEW_COMMON_MODULE, default=[1, 2]), Any) # type: ignore[call-overload] + + iot_modules: ModuleMapping[IotModule] = cast(ModuleMapping[IotModule], {}) + smart_modules: ModuleMapping[SmartModule] = cast(ModuleMapping[SmartModule], {}) + + assert_type(smart_modules["foobar"], SmartModule) + assert_type(iot_modules["foobar"], IotModule) + + # Test for covariance + device_modules_2: ModuleMapping[Module] = iot_modules # noqa: F841 + device_modules_3: ModuleMapping[Module] = smart_modules # noqa: F841 + NEW_MODULE: ModuleName[Module] = NEW_SMART_MODULE # noqa: F841 + NEW_MODULE_2: ModuleName[Module] = NEW_IOT_MODULE # noqa: F841 diff --git a/kasa/plug.py b/kasa/plug.py deleted file mode 100644 index 00796d1c..00000000 --- a/kasa/plug.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Module for a TAPO Plug.""" - -import logging -from abc import ABC - -from .device import Device - -_LOGGER = logging.getLogger(__name__) - - -class Plug(Device, ABC): - """Base class to represent a Plug.""" diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/ledmodule.py index e3113159..587be51c 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/ledmodule.py @@ -2,37 +2,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from ...feature import Feature +from ...interfaces.led import Led from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - -class LedModule(SmartModule): +class LedModule(SmartModule, Led): """Implementation of led controls.""" REQUIRED_COMPONENT = "led" QUERY_GETTER_NAME = "get_led_info" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) - self._add_feature( - Feature( - device=device, - container=self, - id="led", - name="LED", - icon="mdi:led-{state}", - attribute_getter="led", - attribute_setter="set_led", - type=Feature.Type.Switch, - category=Feature.Category.Config, - ) - ) - def query(self) -> dict: """Query to execute during the update cycle.""" return {self.QUERY_GETTER_NAME: {"led_rule": None}} @@ -56,7 +35,7 @@ class LedModule(SmartModule): This should probably be a select with always/never/nightmode. """ rule = "always" if enable else "never" - return await self.call("set_led_info", self.data | {"led_rule": rule}) + return await self.call("set_led_info", dict(self.data, **{"led_rule": rule})) @property def night_mode_settings(self): diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffectmodule.py index bd0eea0a..a06e979a 100644 --- a/kasa/smart/modules/lighteffectmodule.py +++ b/kasa/smart/modules/lighteffectmodule.py @@ -6,14 +6,14 @@ import base64 import copy from typing import TYPE_CHECKING, Any -from ...feature import Feature +from ...interfaces.lighteffect import LightEffect from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightEffectModule(SmartModule): +class LightEffectModule(SmartModule, LightEffect): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_effect" @@ -22,29 +22,11 @@ class LightEffectModule(SmartModule): "L1": "Party", "L2": "Relax", } - LIGHT_EFFECTS_OFF = "Off" def __init__(self, device: SmartDevice, module: str): super().__init__(device, module) self._scenes_names_to_id: dict[str, str] = {} - def _initialize_features(self): - """Initialize features.""" - device = self._device - self._add_feature( - Feature( - device, - id="light_effect", - name="Light effect", - container=self, - attribute_getter="effect", - attribute_setter="set_effect", - category=Feature.Category.Config, - type=Feature.Type.Choice, - choices_getter="effect_list", - ) - ) - def _initialize_effects(self) -> dict[str, dict[str, Any]]: """Return built-in effects.""" # Copy the effects so scene name updates do not update the underlying dict. @@ -64,7 +46,7 @@ class LightEffectModule(SmartModule): return effects @property - def effect_list(self) -> list[str] | None: + def effect_list(self) -> list[str]: """Return built-in effects list. Example: @@ -90,6 +72,9 @@ class LightEffectModule(SmartModule): async def set_effect( self, effect: str, + *, + brightness: int | None = None, + transition: int | None = None, ) -> None: """Set an effect for the device. @@ -108,6 +93,24 @@ class LightEffectModule(SmartModule): params["id"] = effect_id return await self.call("set_dynamic_light_effect_rule_enable", params) + async def set_custom_effect( + self, + effect_dict: dict, + ) -> None: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + raise NotImplementedError( + "Device does not support setting custom effects. " + "Use has_custom_effects to check for support." + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return False + def query(self) -> dict: """Query to execute during the update cycle.""" return {self.QUERY_GETTER_NAME: {"start_index": 0}} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 68b08902..194e7c17 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -5,7 +5,7 @@ from __future__ import annotations import base64 import logging from datetime import datetime, timedelta -from typing import Any, Mapping, Sequence, cast, overload +from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange @@ -16,7 +16,8 @@ from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..fan import Fan from ..feature import Feature -from ..module import ModuleT +from ..module import Module +from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol from .modules import ( Brightness, @@ -61,7 +62,7 @@ class SmartDevice(Bulb, Fan, Device): self._components_raw: dict[str, Any] | None = None self._components: dict[str, int] = {} self._state_information: dict[str, Any] = {} - self._modules: dict[str, SmartModule] = {} + self._modules: dict[str | ModuleName[Module], SmartModule] = {} self._exposes_child_modules = False self._parent: SmartDevice | None = None self._children: Mapping[str, SmartDevice] = {} @@ -102,8 +103,20 @@ class SmartDevice(Bulb, Fan, Device): return list(self._children.values()) @property - def modules(self) -> dict[str, SmartModule]: + def modules(self) -> ModuleMapping[SmartModule]: """Return the device modules.""" + if self._exposes_child_modules: + modules = {k: v for k, v in self._modules.items()} + for child in self._children.values(): + for k, v in child._modules.items(): + if k not in modules: + modules[k] = v + if TYPE_CHECKING: + return cast(ModuleMapping[SmartModule], modules) + return modules + + if TYPE_CHECKING: # Needed for python 3.8 + return cast(ModuleMapping[SmartModule], self._modules) return self._modules def _try_get_response(self, responses: dict, request: str, default=None) -> dict: @@ -315,30 +328,6 @@ class SmartDevice(Bulb, Fan, Device): for feat in module._module_features.values(): self._add_feature(feat) - @overload - def get_module(self, module_type: type[ModuleT]) -> ModuleT | None: ... - - @overload - def get_module(self, module_type: str) -> SmartModule | None: ... - - def get_module( - self, module_type: type[ModuleT] | str - ) -> ModuleT | SmartModule | None: - """Return the module from the device modules or None if not present.""" - if isinstance(module_type, str): - module_name = module_type - elif issubclass(module_type, SmartModule): - module_name = module_type.__name__ - else: - return None - if module_name in self.modules: - return self.modules[module_name] - elif self._exposes_child_modules: - for child in self._children.values(): - if module_name in child.modules: - return child.modules[module_name] - return None - @property def is_cloud_connected(self): """Returns if the device is connected to the cloud.""" diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index 5ca4a8ae..7c73c71e 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -189,6 +189,11 @@ class FakeSmartTransport(BaseTransport): if "current_rule_id" in info["get_dynamic_light_effect_rules"]: del info["get_dynamic_light_effect_rules"]["current_rule_id"] + def _set_led_info(self, info, params): + """Set or remove values as per the device behaviour.""" + info["get_led_info"]["led_status"] = params["led_rule"] != "never" + info["get_led_info"]["led_rule"] = params["led_rule"] + def _send_request(self, request_dict: dict): method = request_dict["method"] params = request_dict["params"] @@ -218,7 +223,9 @@ class FakeSmartTransport(BaseTransport): # SMART fixtures started to be generated missing_result := self.FIXTURE_MISSING_MAP.get(method) ) and missing_result[0] in self.components: - result = copy.deepcopy(missing_result[1]) + # Copy to info so it will work with update methods + info[method] = copy.deepcopy(missing_result[1]) + result = copy.deepcopy(info[method]) retval = {"result": result, "error_code": 0} else: # PARAMS error returned for KS240 when get_device_usage called @@ -239,6 +246,9 @@ class FakeSmartTransport(BaseTransport): elif method == "set_dynamic_light_effect_rule_enable": self._set_light_effect(info, params) return {"error_code": 0} + elif method == "set_led_info": + self._set_led_info(info, params) + return {"error_code": 0} elif method[:4] == "set_": target_method = f"get_{method[4:]}" info[target_method].update(params) diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index 02a396aa..3c00a4d1 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -10,7 +10,7 @@ brightness = parametrize("brightness smart", component_filter="brightness") @brightness async def test_brightness_component(dev: SmartDevice): """Test brightness feature.""" - brightness = dev.get_module("Brightness") + brightness = dev.modules.get("Brightness") assert brightness assert isinstance(dev, SmartDevice) assert "brightness" in dev._components diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py index fc337545..88677c58 100644 --- a/kasa/tests/smart/modules/test_contact.py +++ b/kasa/tests/smart/modules/test_contact.py @@ -1,7 +1,6 @@ import pytest -from kasa import SmartDevice -from kasa.smart.modules import ContactSensor +from kasa import Module, SmartDevice from kasa.tests.device_fixtures import parametrize contact = parametrize( @@ -18,7 +17,7 @@ contact = parametrize( ) async def test_contact_features(dev: SmartDevice, feature, type): """Test that features are registered and work as expected.""" - contact = dev.get_module(ContactSensor) + contact = dev.modules.get(Module.ContactSensor) assert contact is not None prop = getattr(contact, feature) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 37245951..9597471b 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,8 +1,8 @@ import pytest from pytest_mock import MockerFixture +from kasa import Module from kasa.smart import SmartDevice -from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) @@ -11,7 +11,7 @@ fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"S @fan async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan level_feature = fan._module_features["fan_speed_level"] @@ -36,7 +36,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan sleep_feature = fan._module_features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) @@ -55,7 +55,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) - fan = dev.get_module(FanModule) + fan = dev.modules.get(Module.Fan) assert fan device = fan._device assert device.is_fan diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index d0df87ca..8f329f70 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/kasa/tests/smart/modules/test_firmware.py @@ -6,8 +6,8 @@ import logging import pytest from pytest_mock import MockerFixture +from kasa import Module from kasa.smart import SmartDevice -from kasa.smart.modules import Firmware from kasa.smart.modules.firmware import DownloadState from kasa.tests.device_fixtures import parametrize @@ -31,7 +31,7 @@ async def test_firmware_features( dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture ): """Test light effect.""" - fw = dev.get_module(Firmware) + fw = dev.modules.get(Module.Firmware) assert fw if not dev.is_cloud_connected: @@ -51,7 +51,7 @@ async def test_firmware_features( @firmware async def test_update_available_without_cloud(dev: SmartDevice): """Test that update_available returns None when disconnected.""" - fw = dev.get_module(Firmware) + fw = dev.modules.get(Module.Firmware) assert fw if dev.is_cloud_connected: @@ -67,7 +67,7 @@ async def test_firmware_update( """Test updating firmware.""" caplog.set_level(logging.INFO) - fw = dev.get_module(Firmware) + fw = dev.modules.get(Module.Firmware) assert fw upgrade_time = 5 diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index ba1b2293..cc0eee8a 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -1,12 +1,11 @@ from __future__ import annotations from itertools import chain -from typing import cast import pytest from pytest_mock import MockerFixture -from kasa import Device, Feature +from kasa import Device, Feature, Module from kasa.smart.modules import LightEffectModule from kasa.tests.device_fixtures import parametrize @@ -18,8 +17,8 @@ light_effect = parametrize( @light_effect async def test_light_effect(dev: Device, mocker: MockerFixture): """Test light effect.""" - light_effect = cast(LightEffectModule, dev.modules.get("LightEffectModule")) - assert light_effect + light_effect = dev.modules.get(Module.LightEffect) + assert isinstance(light_effect, LightEffectModule) feature = light_effect._module_features["light_effect"] assert feature.type == Feature.Type.Choice diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py new file mode 100644 index 00000000..8f7def95 --- /dev/null +++ b/kasa/tests/test_common_modules.py @@ -0,0 +1,95 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Module +from kasa.tests.device_fixtures import ( + lightstrip, + parametrize, + parametrize_combine, + plug_iot, +) + +led_smart = parametrize( + "has led smart", component_filter="led", protocol_filter={"SMART"} +) +led = parametrize_combine([led_smart, plug_iot]) + +light_effect_smart = parametrize( + "has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} +) +light_effect = parametrize_combine([light_effect_smart, lightstrip]) + + +@led +async def test_led_module(dev: Device, mocker: MockerFixture): + """Test fan speed feature.""" + led_module = dev.modules.get(Module.Led) + assert led_module + feat = led_module._module_features["led"] + + call = mocker.spy(led_module, "call") + await led_module.set_led(True) + assert call.call_count == 1 + await dev.update() + assert led_module.led is True + assert feat.value is True + + await led_module.set_led(False) + assert call.call_count == 2 + await dev.update() + assert led_module.led is False + assert feat.value is False + + await feat.set_value(True) + assert call.call_count == 3 + await dev.update() + assert feat.value is True + assert led_module.led is True + + +@light_effect +async def test_light_effect_module(dev: Device, mocker: MockerFixture): + """Test fan speed feature.""" + light_effect_module = dev.modules[Module.LightEffect] + assert light_effect_module + feat = light_effect_module._module_features["light_effect"] + + call = mocker.spy(light_effect_module, "call") + effect_list = light_effect_module.effect_list + assert "Off" in effect_list + assert effect_list.index("Off") == 0 + assert len(effect_list) > 1 + assert effect_list == feat.choices + + assert light_effect_module.has_custom_effects is not None + + await light_effect_module.set_effect("Off") + assert call.call_count == 1 + await dev.update() + assert light_effect_module.effect == "Off" + assert feat.value == "Off" + + second_effect = effect_list[1] + await light_effect_module.set_effect(second_effect) + assert call.call_count == 2 + await dev.update() + assert light_effect_module.effect == second_effect + assert feat.value == second_effect + + last_effect = effect_list[len(effect_list) - 1] + await light_effect_module.set_effect(last_effect) + assert call.call_count == 3 + await dev.update() + assert light_effect_module.effect == last_effect + assert feat.value == last_effect + + # Test feature set + await feat.set_value(second_effect) + assert call.call_count == 4 + await dev.update() + assert light_effect_module.effect == second_effect + assert feat.value == second_effect + + with pytest.raises(ValueError): + await light_effect_module.set_effect("foobar") + assert call.call_count == 4 diff --git a/kasa/tests/test_iotdevice.py b/kasa/tests/test_iotdevice.py index b4d56291..d5c76192 100644 --- a/kasa/tests/test_iotdevice.py +++ b/kasa/tests/test_iotdevice.py @@ -16,7 +16,7 @@ from voluptuous import ( Schema, ) -from kasa import KasaException +from kasa import KasaException, Module from kasa.iot import IotDevice from .conftest import get_device_for_fixture_protocol, handle_turn_on, turn_on @@ -261,27 +261,26 @@ async def test_modules_not_supported(dev: IotDevice): async def test_get_modules(): - """Test get_modules for child and parent modules.""" + """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( "HS100(US)_2.0_1.5.6.json", "IOT" ) from kasa.iot.modules import Cloud - from kasa.smart.modules import CloudModule # Modules on device - module = dummy_device.get_module("Cloud") + module = dummy_device.modules.get("cloud") assert module assert module._device == dummy_device assert isinstance(module, Cloud) - module = dummy_device.get_module(Cloud) + module = dummy_device.modules.get(Module.IotCloud) assert module assert module._device == dummy_device assert isinstance(module, Cloud) # Invalid modules - module = dummy_device.get_module("DummyModule") + module = dummy_device.modules.get("DummyModule") assert module is None - module = dummy_device.get_module(CloudModule) + module = dummy_device.modules.get(Module.Cloud) assert module is None diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index fc987d2e..f51f1805 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -1,7 +1,6 @@ import pytest from kasa import DeviceType -from kasa.exceptions import KasaException from kasa.iot import IotLightStrip from .conftest import lightstrip @@ -23,7 +22,7 @@ async def test_lightstrip_effect(dev: IotLightStrip): @lightstrip async def test_effects_lightstrip_set_effect(dev: IotLightStrip): - with pytest.raises(KasaException): + with pytest.raises(ValueError): await dev.set_effect("Not real") await dev.set_effect("Candy Cane") diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index bb2f81bf..a0af2cb1 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -9,7 +9,7 @@ from unittest.mock import patch import pytest from pytest_mock import MockerFixture -from kasa import KasaException +from kasa import KasaException, Module from kasa.exceptions import SmartErrorCode from kasa.smart import SmartDevice @@ -123,40 +123,39 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): async def test_get_modules(): - """Test get_modules for child and parent modules.""" + """Test getting modules for child and parent modules.""" dummy_device = await get_device_for_fixture_protocol( "KS240(US)_1.0_1.0.5.json", "SMART" ) - from kasa.iot.modules import AmbientLight - from kasa.smart.modules import CloudModule, FanModule + from kasa.smart.modules import CloudModule # Modules on device - module = dummy_device.get_module("CloudModule") + module = dummy_device.modules.get("CloudModule") assert module assert module._device == dummy_device assert isinstance(module, CloudModule) - module = dummy_device.get_module(CloudModule) + module = dummy_device.modules.get(Module.Cloud) assert module assert module._device == dummy_device assert isinstance(module, CloudModule) # Modules on child - module = dummy_device.get_module("FanModule") + module = dummy_device.modules.get("FanModule") assert module assert module._device != dummy_device assert module._device._parent == dummy_device - module = dummy_device.get_module(FanModule) + module = dummy_device.modules.get(Module.Fan) assert module assert module._device != dummy_device assert module._device._parent == dummy_device # Invalid modules - module = dummy_device.get_module("DummyModule") + module = dummy_device.modules.get("DummyModule") assert module is None - module = dummy_device.get_module(AmbientLight) + module = dummy_device.modules.get(Module.IotAmbientLight) assert module is None From f259a8f16218a46785f4d1fa3469826668438ef6 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sat, 11 May 2024 19:28:18 +0100 Subject: [PATCH 07/11] Make module names consistent and remove redundant module casting (#909) Address the inconsistent naming of smart modules by removing all "Module" suffixes and aligning filenames with class names. Removes the casting of modules to the correct module type now that is is redundant. Update the adding of iot modules to use the ModuleName class rather than a free string. --- kasa/iot/iotbulb.py | 19 +++-- kasa/iot/iotdevice.py | 28 ++++---- kasa/iot/iotdimmer.py | 5 +- kasa/iot/iotlightstrip.py | 4 +- kasa/iot/iotplug.py | 14 ++-- kasa/iot/iotstrip.py | 11 +-- kasa/iot/modules/__init__.py | 6 +- kasa/iot/modules/{ledmodule.py => led.py} | 4 +- .../{lighteffectmodule.py => lighteffect.py} | 4 +- kasa/module.py | 50 ++++++------- kasa/smart/modules/__init__.py | 66 ++++++++--------- .../modules/{alarmmodule.py => alarm.py} | 2 +- .../modules/{autooffmodule.py => autooff.py} | 2 +- .../modules/{battery.py => batterysensor.py} | 0 .../{childdevicemodule.py => childdevice.py} | 2 +- .../modules/{cloudmodule.py => cloud.py} | 2 +- .../modules/{colormodule.py => color.py} | 2 +- .../{colortemp.py => colortemperature.py} | 2 +- .../modules/{contact.py => contactsensor.py} | 0 .../modules/{energymodule.py => energy.py} | 2 +- kasa/smart/modules/{fanmodule.py => fan.py} | 2 +- kasa/smart/modules/frostprotection.py | 2 +- .../{humidity.py => humiditysensor.py} | 0 kasa/smart/modules/{ledmodule.py => led.py} | 4 +- .../{lighteffectmodule.py => lighteffect.py} | 4 +- ...transitionmodule.py => lighttransition.py} | 2 +- .../{reportmodule.py => reportmode.py} | 2 +- .../{temperature.py => temperaturesensor.py} | 0 kasa/smart/modules/{timemodule.py => time.py} | 2 +- .../{waterleak.py => waterleaksensor.py} | 0 kasa/smart/smartdevice.py | 72 ++++++++----------- kasa/tests/smart/modules/test_light_effect.py | 6 +- kasa/tests/test_cli.py | 2 +- kasa/tests/test_smartdevice.py | 10 +-- 34 files changed, 162 insertions(+), 171 deletions(-) rename kasa/iot/modules/{ledmodule.py => led.py} (89%) rename kasa/iot/modules/{lighteffectmodule.py => lighteffect.py} (95%) rename kasa/smart/modules/{alarmmodule.py => alarm.py} (99%) rename kasa/smart/modules/{autooffmodule.py => autooff.py} (98%) rename kasa/smart/modules/{battery.py => batterysensor.py} (100%) rename kasa/smart/modules/{childdevicemodule.py => childdevice.py} (84%) rename kasa/smart/modules/{cloudmodule.py => cloud.py} (97%) rename kasa/smart/modules/{colormodule.py => color.py} (98%) rename kasa/smart/modules/{colortemp.py => colortemperature.py} (98%) rename kasa/smart/modules/{contact.py => contactsensor.py} (100%) rename kasa/smart/modules/{energymodule.py => energy.py} (98%) rename kasa/smart/modules/{fanmodule.py => fan.py} (98%) rename kasa/smart/modules/{humidity.py => humiditysensor.py} (100%) rename kasa/smart/modules/{ledmodule.py => led.py} (93%) rename kasa/smart/modules/{lighteffectmodule.py => lighteffect.py} (96%) rename kasa/smart/modules/{lighttransitionmodule.py => lighttransition.py} (99%) rename kasa/smart/modules/{reportmodule.py => reportmode.py} (96%) rename kasa/smart/modules/{temperature.py => temperaturesensor.py} (100%) rename kasa/smart/modules/{timemodule.py => time.py} (98%) rename kasa/smart/modules/{waterleak.py => waterleaksensor.py} (100%) diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 6819d94b..92bf9814 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -13,6 +13,7 @@ from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..feature import Feature +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage @@ -198,13 +199,17 @@ class IotBulb(IotDevice, Bulb): ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb - self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) - self.add_module("usage", Usage(self, "smartlife.iot.common.schedule")) - self.add_module("antitheft", Antitheft(self, "smartlife.iot.common.anti_theft")) - self.add_module("time", Time(self, "smartlife.iot.common.timesetting")) - self.add_module("emeter", Emeter(self, self.emeter_type)) - self.add_module("countdown", Countdown(self, "countdown")) - self.add_module("cloud", Cloud(self, "smartlife.iot.common.cloud")) + self.add_module( + Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule") + ) + self.add_module(Module.IotUsage, Usage(self, "smartlife.iot.common.schedule")) + self.add_module( + Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft") + ) + self.add_module(Module.IotTime, Time(self, "smartlife.iot.common.timesetting")) + self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) + self.add_module(Module.IotCountdown, Countdown(self, "countdown")) + self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) async def _initialize_features(self): await super()._initialize_features() diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index 762fc06c..e4c1bb13 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -30,7 +30,7 @@ from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..protocol import BaseProtocol from .iotmodule import IotModule -from .modules import Emeter, Time +from .modules import Emeter _LOGGER = logging.getLogger(__name__) @@ -347,7 +347,7 @@ class IotDevice(Device): _LOGGER.debug( "The device has emeter, querying its information along sysinfo" ) - self.add_module("emeter", Emeter(self, self.emeter_type)) + self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) # TODO: perhaps modules should not have unsupported modules, # making separate handling for this unnecessary @@ -440,27 +440,27 @@ class IotDevice(Device): @requires_update def time(self) -> datetime: """Return current time from the device.""" - return cast(Time, self.modules["time"]).time + return self.modules[Module.IotTime].time @property @requires_update def timezone(self) -> dict: """Return the current timezone.""" - return cast(Time, self.modules["time"]).timezone + return self.modules[Module.IotTime].timezone async def get_time(self) -> datetime | None: """Return current time from the device, if available.""" _LOGGER.warning( "Use `time` property instead, this call will be removed in the future." ) - return await cast(Time, self.modules["time"]).get_time() + return await self.modules[Module.IotTime].get_time() async def get_timezone(self) -> dict: """Return timezone information.""" _LOGGER.warning( "Use `timezone` property instead, this call will be removed in the future." ) - return await cast(Time, self.modules["time"]).get_timezone() + return await self.modules[Module.IotTime].get_timezone() @property # type: ignore @requires_update @@ -541,26 +541,26 @@ class IotDevice(Device): def emeter_realtime(self) -> EmeterStatus: """Return current energy readings.""" self._verify_emeter() - return EmeterStatus(cast(Emeter, self.modules["emeter"]).realtime) + return EmeterStatus(self.modules[Module.IotEmeter].realtime) async def get_emeter_realtime(self) -> EmeterStatus: """Retrieve current energy readings.""" self._verify_emeter() - return EmeterStatus(await cast(Emeter, self.modules["emeter"]).get_realtime()) + return EmeterStatus(await self.modules[Module.IotEmeter].get_realtime()) @property @requires_update def emeter_today(self) -> float | None: """Return today's energy consumption in kWh.""" self._verify_emeter() - return cast(Emeter, self.modules["emeter"]).emeter_today + return self.modules[Module.IotEmeter].emeter_today @property @requires_update def emeter_this_month(self) -> float | None: """Return this month's energy consumption in kWh.""" self._verify_emeter() - return cast(Emeter, self.modules["emeter"]).emeter_this_month + return self.modules[Module.IotEmeter].emeter_this_month async def get_emeter_daily( self, year: int | None = None, month: int | None = None, kwh: bool = True @@ -574,7 +574,7 @@ class IotDevice(Device): :return: mapping of day of month to value """ self._verify_emeter() - return await cast(Emeter, self.modules["emeter"]).get_daystat( + return await self.modules[Module.IotEmeter].get_daystat( year=year, month=month, kwh=kwh ) @@ -589,15 +589,13 @@ class IotDevice(Device): :return: dict: mapping of month to value """ self._verify_emeter() - return await cast(Emeter, self.modules["emeter"]).get_monthstat( - year=year, kwh=kwh - ) + return await self.modules[Module.IotEmeter].get_monthstat(year=year, kwh=kwh) @requires_update async def erase_emeter_stats(self) -> dict: """Erase energy meter statistics.""" self._verify_emeter() - return await cast(Emeter, self.modules["emeter"]).erase_stats() + return await self.modules[Module.IotEmeter].erase_stats() @requires_update async def current_consumption(self) -> float: diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index cfe937b8..fed9e7e7 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -8,6 +8,7 @@ from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..feature import Feature +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug @@ -81,8 +82,8 @@ class IotDimmer(IotPlug): self._device_type = DeviceType.Dimmer # TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be figured out what's the best approach to detect support - self.add_module("motion", Motion(self, "smartlife.iot.PIR")) - self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS")) + self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) + self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) async def _initialize_features(self): await super()._initialize_features() diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index a120be7a..7cdbe43b 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -9,7 +9,7 @@ from ..protocol import BaseProtocol from .effects import EFFECT_NAMES_V1 from .iotbulb import IotBulb from .iotdevice import KasaException, requires_update -from .modules.lighteffectmodule import LightEffectModule +from .modules.lighteffect import LightEffect class IotLightStrip(IotBulb): @@ -58,7 +58,7 @@ class IotLightStrip(IotBulb): self._device_type = DeviceType.LightStrip self.add_module( Module.LightEffect, - LightEffectModule(self, "smartlife.iot.lighting_effect"), + LightEffect(self, "smartlife.iot.lighting_effect"), ) @property # type: ignore diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 22238c7a..6aace4f8 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -9,7 +9,7 @@ from ..deviceconfig import DeviceConfig from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, requires_update -from .modules import Antitheft, Cloud, LedModule, Schedule, Time, Usage +from .modules import Antitheft, Cloud, Led, Schedule, Time, Usage _LOGGER = logging.getLogger(__name__) @@ -53,12 +53,12 @@ class IotPlug(IotDevice): ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug - self.add_module("schedule", Schedule(self, "schedule")) - self.add_module("usage", Usage(self, "schedule")) - self.add_module("antitheft", Antitheft(self, "anti_theft")) - self.add_module("time", Time(self, "time")) - self.add_module("cloud", Cloud(self, "cnCloud")) - self.add_module(Module.Led, LedModule(self, "system")) + self.add_module(Module.IotSchedule, Schedule(self, "schedule")) + self.add_module(Module.IotUsage, Usage(self, "schedule")) + self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) + self.add_module(Module.IotTime, Time(self, "time")) + self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) + self.add_module(Module.Led, Led(self, "system")) @property # type: ignore @requires_update diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index ab14abb0..4aa966e1 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -10,6 +10,7 @@ from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..exceptions import KasaException +from ..module import Module from ..protocol import BaseProtocol from .iotdevice import ( EmeterStatus, @@ -95,11 +96,11 @@ class IotStrip(IotDevice): super().__init__(host=host, config=config, protocol=protocol) self.emeter_type = "emeter" self._device_type = DeviceType.Strip - self.add_module("antitheft", Antitheft(self, "anti_theft")) - self.add_module("schedule", Schedule(self, "schedule")) - self.add_module("usage", Usage(self, "schedule")) - self.add_module("time", Time(self, "time")) - self.add_module("countdown", Countdown(self, "countdown")) + self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) + self.add_module(Module.IotSchedule, Schedule(self, "schedule")) + self.add_module(Module.IotUsage, Usage(self, "schedule")) + self.add_module(Module.IotTime, Time(self, "time")) + self.add_module(Module.IotCountdown, Countdown(self, "countdown")) @property # type: ignore @requires_update diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index f061e607..e0febfd4 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -5,7 +5,8 @@ from .antitheft import Antitheft from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter -from .ledmodule import LedModule +from .led import Led +from .lighteffect import LightEffect from .motion import Motion from .rulemodule import Rule, RuleModule from .schedule import Schedule @@ -18,7 +19,8 @@ __all__ = [ "Cloud", "Countdown", "Emeter", - "LedModule", + "Led", + "LightEffect", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/ledmodule.py b/kasa/iot/modules/led.py similarity index 89% rename from kasa/iot/modules/ledmodule.py rename to kasa/iot/modules/led.py index 6b3c6194..6c4ca02a 100644 --- a/kasa/iot/modules/ledmodule.py +++ b/kasa/iot/modules/led.py @@ -2,11 +2,11 @@ from __future__ import annotations -from ...interfaces.led import Led +from ...interfaces.led import Led as LedInterface from ..iotmodule import IotModule -class LedModule(IotModule, Led): +class Led(IotModule, LedInterface): """Implementation of led controls.""" def query(self) -> dict: diff --git a/kasa/iot/modules/lighteffectmodule.py b/kasa/iot/modules/lighteffect.py similarity index 95% rename from kasa/iot/modules/lighteffectmodule.py rename to kasa/iot/modules/lighteffect.py index c53de192..2d40fb54 100644 --- a/kasa/iot/modules/lighteffectmodule.py +++ b/kasa/iot/modules/lighteffect.py @@ -2,12 +2,12 @@ from __future__ import annotations -from ...interfaces.lighteffect import LightEffect +from ...interfaces.lighteffect import LightEffect as LightEffectInterface from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 from ..iotmodule import IotModule -class LightEffectModule(IotModule, LightEffect): +class LightEffect(IotModule, LightEffectInterface): """Implementation of dynamic light effects.""" @property diff --git a/kasa/module.py b/kasa/module.py index b65f0499..55eeea18 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -15,7 +15,7 @@ from .feature import Feature from .modulemapping import ModuleName if TYPE_CHECKING: - from .device import Device as DeviceType # avoid name clash with Device module + from .device import Device from .interfaces.led import Led from .interfaces.lighteffect import LightEffect from .iot import modules as iot @@ -34,8 +34,8 @@ class Module(ABC): """ # Common Modules - LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffectModule") - Led: Final[ModuleName[Led]] = ModuleName("LedModule") + LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffect") + Led: Final[ModuleName[Led]] = ModuleName("Led") # IOT only Modules IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") @@ -49,43 +49,43 @@ class Module(ABC): IotTime: Final[ModuleName[iot.Time]] = ModuleName("time") # SMART only Modules - Alarm: Final[ModuleName[smart.AlarmModule]] = ModuleName("AlarmModule") - AutoOff: Final[ModuleName[smart.AutoOffModule]] = ModuleName("AutoOffModule") + Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm") + AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") - ChildDevice: Final[ModuleName[smart.ChildDeviceModule]] = ModuleName( - "ChildDeviceModule" - ) - Cloud: Final[ModuleName[smart.CloudModule]] = ModuleName("CloudModule") - Color: Final[ModuleName[smart.ColorModule]] = ModuleName("ColorModule") - ColorTemp: Final[ModuleName[smart.ColorTemperatureModule]] = ModuleName( - "ColorTemperatureModule" + ChildDevice: Final[ModuleName[smart.ChildDevice]] = ModuleName("ChildDevice") + Cloud: Final[ModuleName[smart.Cloud]] = ModuleName("Cloud") + Color: Final[ModuleName[smart.Color]] = ModuleName("Color") + ColorTemperature: Final[ModuleName[smart.ColorTemperature]] = ModuleName( + "ColorTemperature" ) ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor") - Device: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") - Energy: Final[ModuleName[smart.EnergyModule]] = ModuleName("EnergyModule") - Fan: Final[ModuleName[smart.FanModule]] = ModuleName("FanModule") + DeviceModule: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") + Energy: Final[ModuleName[smart.Energy]] = ModuleName("Energy") + Fan: Final[ModuleName[smart.Fan]] = ModuleName("Fan") Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware") - FrostProtection: Final[ModuleName[smart.FrostProtectionModule]] = ModuleName( - "FrostProtectionModule" + FrostProtection: Final[ModuleName[smart.FrostProtection]] = ModuleName( + "FrostProtection" ) - Humidity: Final[ModuleName[smart.HumiditySensor]] = ModuleName("HumiditySensor") - LightTransition: Final[ModuleName[smart.LightTransitionModule]] = ModuleName( - "LightTransitionModule" + HumiditySensor: Final[ModuleName[smart.HumiditySensor]] = ModuleName( + "HumiditySensor" ) - Report: Final[ModuleName[smart.ReportModule]] = ModuleName("ReportModule") - Temperature: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( + LightTransition: Final[ModuleName[smart.LightTransition]] = ModuleName( + "LightTransition" + ) + ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode") + TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( "TemperatureSensor" ) - TemperatureSensor: Final[ModuleName[smart.TemperatureControl]] = ModuleName( + TemperatureControl: Final[ModuleName[smart.TemperatureControl]] = ModuleName( "TemperatureControl" ) - Time: Final[ModuleName[smart.TimeModule]] = ModuleName("TimeModule") + Time: Final[ModuleName[smart.Time]] = ModuleName("Time") WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName( "WaterleakSensor" ) - def __init__(self, device: DeviceType, module: str): + def __init__(self, device: Device, module: str): self._device = device self._module = module self._module_features: dict[str, Feature] = {} diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index b0956b80..e119e067 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -1,51 +1,51 @@ """Modules for SMART devices.""" -from .alarmmodule import AlarmModule -from .autooffmodule import AutoOffModule -from .battery import BatterySensor +from .alarm import Alarm +from .autooff import AutoOff +from .batterysensor import BatterySensor from .brightness import Brightness -from .childdevicemodule import ChildDeviceModule -from .cloudmodule import CloudModule -from .colormodule import ColorModule -from .colortemp import ColorTemperatureModule -from .contact import ContactSensor +from .childdevice import ChildDevice +from .cloud import Cloud +from .color import Color +from .colortemperature import ColorTemperature +from .contactsensor import ContactSensor from .devicemodule import DeviceModule -from .energymodule import EnergyModule -from .fanmodule import FanModule +from .energy import Energy +from .fan import Fan from .firmware import Firmware -from .frostprotection import FrostProtectionModule -from .humidity import HumiditySensor -from .ledmodule import LedModule -from .lighteffectmodule import LightEffectModule -from .lighttransitionmodule import LightTransitionModule -from .reportmodule import ReportModule -from .temperature import TemperatureSensor +from .frostprotection import FrostProtection +from .humiditysensor import HumiditySensor +from .led import Led +from .lighteffect import LightEffect +from .lighttransition import LightTransition +from .reportmode import ReportMode from .temperaturecontrol import TemperatureControl -from .timemodule import TimeModule -from .waterleak import WaterleakSensor +from .temperaturesensor import TemperatureSensor +from .time import Time +from .waterleaksensor import WaterleakSensor __all__ = [ - "AlarmModule", - "TimeModule", - "EnergyModule", + "Alarm", + "Time", + "Energy", "DeviceModule", - "ChildDeviceModule", + "ChildDevice", "BatterySensor", "HumiditySensor", "TemperatureSensor", "TemperatureControl", - "ReportModule", - "AutoOffModule", - "LedModule", + "ReportMode", + "AutoOff", + "Led", "Brightness", - "FanModule", + "Fan", "Firmware", - "CloudModule", - "LightEffectModule", - "LightTransitionModule", - "ColorTemperatureModule", - "ColorModule", + "Cloud", + "LightEffect", + "LightTransition", + "ColorTemperature", + "Color", "WaterleakSensor", "ContactSensor", - "FrostProtectionModule", + "FrostProtection", ] diff --git a/kasa/smart/modules/alarmmodule.py b/kasa/smart/modules/alarm.py similarity index 99% rename from kasa/smart/modules/alarmmodule.py rename to kasa/smart/modules/alarm.py index 845eb65a..f033496a 100644 --- a/kasa/smart/modules/alarmmodule.py +++ b/kasa/smart/modules/alarm.py @@ -6,7 +6,7 @@ from ...feature import Feature from ..smartmodule import SmartModule -class AlarmModule(SmartModule): +class Alarm(SmartModule): """Implementation of alarm module.""" REQUIRED_COMPONENT = "alarm" diff --git a/kasa/smart/modules/autooffmodule.py b/kasa/smart/modules/autooff.py similarity index 98% rename from kasa/smart/modules/autooffmodule.py rename to kasa/smart/modules/autooff.py index cb8d5e57..385364fa 100644 --- a/kasa/smart/modules/autooffmodule.py +++ b/kasa/smart/modules/autooff.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class AutoOffModule(SmartModule): +class AutoOff(SmartModule): """Implementation of auto off module.""" REQUIRED_COMPONENT = "auto_off" diff --git a/kasa/smart/modules/battery.py b/kasa/smart/modules/batterysensor.py similarity index 100% rename from kasa/smart/modules/battery.py rename to kasa/smart/modules/batterysensor.py diff --git a/kasa/smart/modules/childdevicemodule.py b/kasa/smart/modules/childdevice.py similarity index 84% rename from kasa/smart/modules/childdevicemodule.py rename to kasa/smart/modules/childdevice.py index 9f4710b2..5713eff4 100644 --- a/kasa/smart/modules/childdevicemodule.py +++ b/kasa/smart/modules/childdevice.py @@ -3,7 +3,7 @@ from ..smartmodule import SmartModule -class ChildDeviceModule(SmartModule): +class ChildDevice(SmartModule): """Implementation for child devices.""" REQUIRED_COMPONENT = "child_device" diff --git a/kasa/smart/modules/cloudmodule.py b/kasa/smart/modules/cloud.py similarity index 97% rename from kasa/smart/modules/cloudmodule.py rename to kasa/smart/modules/cloud.py index 8b9d8f41..1b64f090 100644 --- a/kasa/smart/modules/cloudmodule.py +++ b/kasa/smart/modules/cloud.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class CloudModule(SmartModule): +class Cloud(SmartModule): """Implementation of cloud module.""" QUERY_GETTER_NAME = "get_connect_cloud_state" diff --git a/kasa/smart/modules/colormodule.py b/kasa/smart/modules/color.py similarity index 98% rename from kasa/smart/modules/colormodule.py rename to kasa/smart/modules/color.py index 716d4c44..979d4fec 100644 --- a/kasa/smart/modules/colormodule.py +++ b/kasa/smart/modules/color.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class ColorModule(SmartModule): +class Color(SmartModule): """Implementation of color module.""" REQUIRED_COMPONENT = "color" diff --git a/kasa/smart/modules/colortemp.py b/kasa/smart/modules/colortemperature.py similarity index 98% rename from kasa/smart/modules/colortemp.py rename to kasa/smart/modules/colortemperature.py index d6b43d02..88d5ea21 100644 --- a/kasa/smart/modules/colortemp.py +++ b/kasa/smart/modules/colortemperature.py @@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_TEMP_RANGE = [2500, 6500] -class ColorTemperatureModule(SmartModule): +class ColorTemperature(SmartModule): """Implementation of color temp module.""" REQUIRED_COMPONENT = "color_temperature" diff --git a/kasa/smart/modules/contact.py b/kasa/smart/modules/contactsensor.py similarity index 100% rename from kasa/smart/modules/contact.py rename to kasa/smart/modules/contactsensor.py diff --git a/kasa/smart/modules/energymodule.py b/kasa/smart/modules/energy.py similarity index 98% rename from kasa/smart/modules/energymodule.py rename to kasa/smart/modules/energy.py index 9cfe8cfb..55b5088e 100644 --- a/kasa/smart/modules/energymodule.py +++ b/kasa/smart/modules/energy.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class EnergyModule(SmartModule): +class Energy(SmartModule): """Implementation of energy monitoring module.""" REQUIRED_COMPONENT = "energy_monitoring" diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fan.py similarity index 98% rename from kasa/smart/modules/fanmodule.py rename to kasa/smart/modules/fan.py index 6eeaa4d4..3d8cc7eb 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fan.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class FanModule(SmartModule): +class Fan(SmartModule): """Implementation of fan_control module.""" REQUIRED_COMPONENT = "fan_control" diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py index cedaf78b..ee93d299 100644 --- a/kasa/smart/modules/frostprotection.py +++ b/kasa/smart/modules/frostprotection.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class FrostProtectionModule(SmartModule): +class FrostProtection(SmartModule): """Implementation for frost protection module. This basically turns the thermostat on and off. diff --git a/kasa/smart/modules/humidity.py b/kasa/smart/modules/humiditysensor.py similarity index 100% rename from kasa/smart/modules/humidity.py rename to kasa/smart/modules/humiditysensor.py diff --git a/kasa/smart/modules/ledmodule.py b/kasa/smart/modules/led.py similarity index 93% rename from kasa/smart/modules/ledmodule.py rename to kasa/smart/modules/led.py index 587be51c..230b83d9 100644 --- a/kasa/smart/modules/ledmodule.py +++ b/kasa/smart/modules/led.py @@ -2,11 +2,11 @@ from __future__ import annotations -from ...interfaces.led import Led +from ...interfaces.led import Led as LedInterface from ..smartmodule import SmartModule -class LedModule(SmartModule, Led): +class Led(SmartModule, LedInterface): """Implementation of led controls.""" REQUIRED_COMPONENT = "led" diff --git a/kasa/smart/modules/lighteffectmodule.py b/kasa/smart/modules/lighteffect.py similarity index 96% rename from kasa/smart/modules/lighteffectmodule.py rename to kasa/smart/modules/lighteffect.py index a06e979a..4f049576 100644 --- a/kasa/smart/modules/lighteffectmodule.py +++ b/kasa/smart/modules/lighteffect.py @@ -6,14 +6,14 @@ import base64 import copy from typing import TYPE_CHECKING, Any -from ...interfaces.lighteffect import LightEffect +from ...interfaces.lighteffect import LightEffect as LightEffectInterface from ..smartmodule import SmartModule if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightEffectModule(SmartModule, LightEffect): +class LightEffect(SmartModule, LightEffectInterface): """Implementation of dynamic light effects.""" REQUIRED_COMPONENT = "light_effect" diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransition.py similarity index 99% rename from kasa/smart/modules/lighttransitionmodule.py rename to kasa/smart/modules/lighttransition.py index f213d9ac..a11c7d95 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransition.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class LightTransitionModule(SmartModule): +class LightTransition(SmartModule): """Implementation of gradual on/off.""" REQUIRED_COMPONENT = "on_off_gradually" diff --git a/kasa/smart/modules/reportmodule.py b/kasa/smart/modules/reportmode.py similarity index 96% rename from kasa/smart/modules/reportmodule.py rename to kasa/smart/modules/reportmode.py index 16827a8c..f0af4c1c 100644 --- a/kasa/smart/modules/reportmodule.py +++ b/kasa/smart/modules/reportmode.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class ReportModule(SmartModule): +class ReportMode(SmartModule): """Implementation of report module.""" REQUIRED_COMPONENT = "report_mode" diff --git a/kasa/smart/modules/temperature.py b/kasa/smart/modules/temperaturesensor.py similarity index 100% rename from kasa/smart/modules/temperature.py rename to kasa/smart/modules/temperaturesensor.py diff --git a/kasa/smart/modules/timemodule.py b/kasa/smart/modules/time.py similarity index 98% rename from kasa/smart/modules/timemodule.py rename to kasa/smart/modules/time.py index 23814f57..958cf9e2 100644 --- a/kasa/smart/modules/timemodule.py +++ b/kasa/smart/modules/time.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: from ..smartdevice import SmartDevice -class TimeModule(SmartModule): +class Time(SmartModule): """Implementation of device_local_time.""" REQUIRED_COMPONENT = "time" diff --git a/kasa/smart/modules/waterleak.py b/kasa/smart/modules/waterleaksensor.py similarity index 100% rename from kasa/smart/modules/waterleak.py rename to kasa/smart/modules/waterleaksensor.py diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 194e7c17..122c943b 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -20,15 +20,10 @@ from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol from .modules import ( - Brightness, - CloudModule, - ColorModule, - ColorTemperatureModule, + Cloud, DeviceModule, - EnergyModule, - FanModule, Firmware, - TimeModule, + Time, ) from .smartmodule import SmartModule @@ -39,7 +34,7 @@ _LOGGER = logging.getLogger(__name__) # the child but only work on the parent. See longer note below in _initialize_modules. # This list should be updated when creating new modules that could have the # same issue, homekit perhaps? -WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] +WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] # Device must go last as the other interfaces also inherit Device @@ -329,11 +324,11 @@ class SmartDevice(Bulb, Fan, Device): self._add_feature(feat) @property - def is_cloud_connected(self): + def is_cloud_connected(self) -> bool: """Returns if the device is connected to the cloud.""" - if "CloudModule" not in self.modules: + if Module.Cloud not in self.modules: return False - return self.modules["CloudModule"].is_connected + return self.modules[Module.Cloud].is_connected @property def sys_info(self) -> dict[str, Any]: @@ -357,10 +352,10 @@ class SmartDevice(Bulb, Fan, Device): def time(self) -> datetime: """Return the time.""" # TODO: Default to parent's time module for child devices - if self._parent and "TimeModule" in self.modules: - _timemod = cast(TimeModule, self._parent.modules["TimeModule"]) # noqa: F405 + if self._parent and Module.Time in self.modules: + _timemod = self._parent.modules[Module.Time] else: - _timemod = cast(TimeModule, self.modules["TimeModule"]) # noqa: F405 + _timemod = self.modules[Module.Time] return _timemod.time @@ -437,7 +432,7 @@ class SmartDevice(Bulb, Fan, Device): @property def has_emeter(self) -> bool: """Return if the device has emeter.""" - return "EnergyModule" in self.modules + return Module.Energy in self.modules @property def is_dimmer(self) -> bool: @@ -479,19 +474,19 @@ class SmartDevice(Bulb, Fan, Device): @property def emeter_realtime(self) -> EmeterStatus: """Get the emeter status.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = self.modules[Module.Energy] return energy.emeter_realtime @property def emeter_this_month(self) -> float | None: """Get the emeter value for this month.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = self.modules[Module.Energy] return energy.emeter_this_month @property def emeter_today(self) -> float | None: """Get the emeter value for today.""" - energy = cast(EnergyModule, self.modules["EnergyModule"]) + energy = self.modules[Module.Energy] return energy.emeter_today @property @@ -503,8 +498,7 @@ class SmartDevice(Bulb, Fan, Device): ): return None on_time = cast(float, on_time) - if (timemod := self.modules.get("TimeModule")) is not None: - timemod = cast(TimeModule, timemod) # noqa: F405 + if (timemod := self.modules.get(Module.Time)) is not None: return timemod.time - timedelta(seconds=on_time) else: # We have no device time, use current local time. return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) @@ -650,37 +644,37 @@ class SmartDevice(Bulb, Fan, Device): @property def is_fan(self) -> bool: """Return True if the device is a fan.""" - return "FanModule" in self.modules + return Module.Fan in self.modules @property def fan_speed_level(self) -> int: """Return fan speed level.""" if not self.is_fan: raise KasaException("Device is not a Fan") - return cast(FanModule, self.modules["FanModule"]).fan_speed_level + return self.modules[Module.Fan].fan_speed_level async def set_fan_speed_level(self, level: int): """Set fan speed level.""" if not self.is_fan: raise KasaException("Device is not a Fan") - await cast(FanModule, self.modules["FanModule"]).set_fan_speed_level(level) + await self.modules[Module.Fan].set_fan_speed_level(level) # Bulb interface methods @property def is_color(self) -> bool: """Whether the bulb supports color changes.""" - return "ColorModule" in self.modules + return Module.Color in self.modules @property def is_dimmable(self) -> bool: """Whether the bulb supports brightness changes.""" - return "Brightness" in self.modules + return Module.Brightness in self.modules @property def is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" - return "ColorTemperatureModule" in self.modules + return Module.ColorTemperature in self.modules @property def valid_temperature_range(self) -> ColorTempRange: @@ -691,9 +685,7 @@ class SmartDevice(Bulb, Fan, Device): if not self.is_variable_color_temp: raise KasaException("Color temperature not supported") - return cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).valid_temperature_range + return self.modules[Module.ColorTemperature].valid_temperature_range @property def hsv(self) -> HSV: @@ -704,7 +696,7 @@ class SmartDevice(Bulb, Fan, Device): if not self.is_color: raise KasaException("Bulb does not support color.") - return cast(ColorModule, self.modules["ColorModule"]).hsv + return self.modules[Module.Color].hsv @property def color_temp(self) -> int: @@ -712,9 +704,7 @@ class SmartDevice(Bulb, Fan, Device): if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - return cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).color_temp + return self.modules[Module.ColorTemperature].color_temp @property def brightness(self) -> int: @@ -722,7 +712,7 @@ class SmartDevice(Bulb, Fan, Device): if not self.is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") - return cast(Brightness, self.modules["Brightness"]).brightness + return self.modules[Module.Brightness].brightness async def set_hsv( self, @@ -744,9 +734,7 @@ class SmartDevice(Bulb, Fan, Device): if not self.is_color: raise KasaException("Bulb does not support color.") - return await cast(ColorModule, self.modules["ColorModule"]).set_hsv( - hue, saturation, value - ) + return await self.modules[Module.Color].set_hsv(hue, saturation, value) async def set_color_temp( self, temp: int, *, brightness=None, transition: int | None = None @@ -760,9 +748,7 @@ class SmartDevice(Bulb, Fan, Device): """ if not self.is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") - return await cast( - ColorTemperatureModule, self.modules["ColorTemperatureModule"] - ).set_color_temp(temp) + return await self.modules[Module.ColorTemperature].set_color_temp(temp) async def set_brightness( self, brightness: int, *, transition: int | None = None @@ -777,9 +763,7 @@ class SmartDevice(Bulb, Fan, Device): if not self.is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") - return await cast(Brightness, self.modules["Brightness"]).set_brightness( - brightness - ) + return await self.modules[Module.Brightness].set_brightness(brightness) @property def presets(self) -> list[BulbPreset]: @@ -789,4 +773,4 @@ class SmartDevice(Bulb, Fan, Device): @property def has_effects(self) -> bool: """Return True if the device supports effects.""" - return "LightEffectModule" in self.modules + return Module.LightEffect in self.modules diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index cc0eee8a..56c3f096 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -6,7 +6,7 @@ import pytest from pytest_mock import MockerFixture from kasa import Device, Feature, Module -from kasa.smart.modules import LightEffectModule +from kasa.smart.modules import LightEffect from kasa.tests.device_fixtures import parametrize light_effect = parametrize( @@ -18,7 +18,7 @@ light_effect = parametrize( async def test_light_effect(dev: Device, mocker: MockerFixture): """Test light effect.""" light_effect = dev.modules.get(Module.LightEffect) - assert isinstance(light_effect, LightEffectModule) + assert isinstance(light_effect, LightEffect) feature = light_effect._module_features["light_effect"] assert feature.type == Feature.Type.Choice @@ -28,7 +28,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture): assert feature.choices for effect in chain(reversed(feature.choices), feature.choices): await light_effect.set_effect(effect) - enable = effect != LightEffectModule.LIGHT_EFFECTS_OFF + enable = effect != LightEffect.LIGHT_EFFECTS_OFF params: dict[str, bool | str] = {"enable": enable} if enable: params["id"] = light_effect._scenes_names_to_id[effect] diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 7addd434..a438aa97 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -737,7 +737,7 @@ async def test_feature_set(mocker, runner): dummy_device = await get_device_for_fixture_protocol( "P300(EU)_1.0_1.0.13.json", "SMART" ) - led_setter = mocker.patch("kasa.smart.modules.ledmodule.LedModule.set_led") + led_setter = mocker.patch("kasa.smart.modules.led.Led.set_led") mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) res = await runner.invoke( diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index a0af2cb1..ed9e5721 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -127,21 +127,21 @@ async def test_get_modules(): dummy_device = await get_device_for_fixture_protocol( "KS240(US)_1.0_1.0.5.json", "SMART" ) - from kasa.smart.modules import CloudModule + from kasa.smart.modules import Cloud # Modules on device - module = dummy_device.modules.get("CloudModule") + module = dummy_device.modules.get("Cloud") assert module assert module._device == dummy_device - assert isinstance(module, CloudModule) + assert isinstance(module, Cloud) module = dummy_device.modules.get(Module.Cloud) assert module assert module._device == dummy_device - assert isinstance(module, CloudModule) + assert isinstance(module, Cloud) # Modules on child - module = dummy_device.modules.get("FanModule") + module = dummy_device.modules.get("Fan") assert module assert module._device != dummy_device assert module._device._parent == dummy_device From d7b00336f4d44e9506ffcb832d5c82cc23db3eb5 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Sat, 11 May 2024 19:40:08 +0100 Subject: [PATCH 08/11] Rename bulb interface to light and move fan and light interface to interfaces (#910) Also rename BulbPreset to LightPreset. --- kasa/__init__.py | 10 ++++---- kasa/cli.py | 6 ++--- kasa/interfaces/__init__.py | 14 +++++++++++ kasa/{ => interfaces}/fan.py | 2 +- kasa/{bulb.py => interfaces/light.py} | 12 +++++----- kasa/iot/iotbulb.py | 14 +++++------ kasa/smart/modules/color.py | 2 +- kasa/smart/modules/colortemperature.py | 2 +- kasa/smart/smartdevice.py | 8 +++---- kasa/tests/test_bulb.py | 32 +++++++++++++------------- kasa/tests/test_device.py | 1 + 11 files changed, 59 insertions(+), 44 deletions(-) create mode 100644 kasa/interfaces/__init__.py rename kasa/{ => interfaces}/fan.py (93%) rename kasa/{bulb.py => interfaces/light.py} (94%) diff --git a/kasa/__init__.py b/kasa/__init__.py index e9f64c70..8428154e 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -16,7 +16,6 @@ from importlib.metadata import version from typing import TYPE_CHECKING from warnings import warn -from kasa.bulb import Bulb, BulbPreset from kasa.credentials import Credentials from kasa.device import Device from kasa.device_type import DeviceType @@ -36,6 +35,7 @@ from kasa.exceptions import ( UnsupportedDeviceError, ) from kasa.feature import Feature +from kasa.interfaces.light import Light, LightPreset from kasa.iotprotocol import ( IotProtocol, _deprecated_TPLinkSmartHomeProtocol, # noqa: F401 @@ -52,14 +52,14 @@ __all__ = [ "BaseProtocol", "IotProtocol", "SmartProtocol", - "BulbPreset", + "LightPreset", "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", "Feature", "EmeterStatus", "Device", - "Bulb", + "Light", "Plug", "Module", "KasaException", @@ -84,7 +84,7 @@ deprecated_smart_devices = { "SmartLightStrip": iot.IotLightStrip, "SmartStrip": iot.IotStrip, "SmartDimmer": iot.IotDimmer, - "SmartBulbPreset": BulbPreset, + "SmartBulbPreset": LightPreset, } deprecated_exceptions = { "SmartDeviceException": KasaException, @@ -124,7 +124,7 @@ if TYPE_CHECKING: SmartLightStrip = iot.IotLightStrip SmartStrip = iot.IotStrip SmartDimmer = iot.IotDimmer - SmartBulbPreset = BulbPreset + SmartBulbPreset = LightPreset SmartDeviceException = KasaException UnsupportedDeviceException = UnsupportedDeviceError diff --git a/kasa/cli.py b/kasa/cli.py index 696dee27..d51679a2 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -18,7 +18,6 @@ from pydantic.v1 import ValidationError from kasa import ( AuthenticationError, - Bulb, ConnectionType, Credentials, Device, @@ -28,6 +27,7 @@ from kasa import ( EncryptType, Feature, KasaException, + Light, UnsupportedDeviceError, ) from kasa.discover import DiscoveryResult @@ -859,7 +859,7 @@ async def usage(dev: Device, year, month, erase): @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def brightness(dev: Bulb, brightness: int, transition: int): +async def brightness(dev: Light, brightness: int, transition: int): """Get or set brightness.""" if not dev.is_dimmable: echo("This device does not support brightness.") @@ -879,7 +879,7 @@ async def brightness(dev: Bulb, brightness: int, transition: int): ) @click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: Bulb, temperature: int, transition: int): +async def temperature(dev: Light, temperature: int, transition: int): """Get or set color temperature.""" if not dev.is_variable_color_temp: echo("Device does not support color temperature") diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py new file mode 100644 index 00000000..d8d089c5 --- /dev/null +++ b/kasa/interfaces/__init__.py @@ -0,0 +1,14 @@ +"""Package for interfaces.""" + +from .fan import Fan +from .led import Led +from .light import Light, LightPreset +from .lighteffect import LightEffect + +__all__ = [ + "Fan", + "Led", + "Light", + "LightEffect", + "LightPreset", +] diff --git a/kasa/fan.py b/kasa/interfaces/fan.py similarity index 93% rename from kasa/fan.py rename to kasa/interfaces/fan.py index e881136e..767fe89f 100644 --- a/kasa/fan.py +++ b/kasa/interfaces/fan.py @@ -4,7 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod -from .device import Device +from ..device import Device class Fan(Device, ABC): diff --git a/kasa/bulb.py b/kasa/interfaces/light.py similarity index 94% rename from kasa/bulb.py rename to kasa/interfaces/light.py index 52a722d9..141be1fd 100644 --- a/kasa/bulb.py +++ b/kasa/interfaces/light.py @@ -7,7 +7,7 @@ from typing import NamedTuple, Optional from pydantic.v1 import BaseModel -from .device import Device +from ..device import Device class ColorTempRange(NamedTuple): @@ -25,8 +25,8 @@ class HSV(NamedTuple): value: int -class BulbPreset(BaseModel): - """Bulb configuration preset.""" +class LightPreset(BaseModel): + """Light configuration preset.""" index: int brightness: int @@ -42,8 +42,8 @@ class BulbPreset(BaseModel): mode: Optional[int] # noqa: UP007 -class Bulb(Device, ABC): - """Base class for TP-Link Bulb.""" +class Light(Device, ABC): + """Base class for TP-Link Light.""" def _raise_for_invalid_brightness(self, value): if not isinstance(value, int) or not (0 <= value <= 100): @@ -135,5 +135,5 @@ class Bulb(Device, ABC): @property @abstractmethod - def presets(self) -> list[BulbPreset]: + def presets(self) -> list[LightPreset]: """Return a list of available bulb setting presets.""" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index 92bf9814..f6135fd1 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -9,10 +9,10 @@ from typing import Optional, cast from pydantic.v1 import BaseModel, Field, root_validator -from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..feature import Feature +from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update @@ -88,7 +88,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"} _LOGGER = logging.getLogger(__name__) -class IotBulb(IotDevice, Bulb): +class IotBulb(IotDevice, Light): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -170,9 +170,9 @@ class IotBulb(IotDevice, Bulb): Bulb configuration presets can be accessed using the :func:`presets` property: >>> bulb.presets - [BulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), BulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), BulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] + [LightPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), LightPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), LightPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] - To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` + To modify an existing preset, pass :class:`~kasa.smartbulb.LightPreset` instance to :func:`save_preset` method: >>> preset = bulb.presets[0] @@ -523,11 +523,11 @@ class IotBulb(IotDevice, Bulb): @property # type: ignore @requires_update - def presets(self) -> list[BulbPreset]: + def presets(self) -> list[LightPreset]: """Return a list of available bulb setting presets.""" - return [BulbPreset(**vals) for vals in self.sys_info["preferred_state"]] + return [LightPreset(**vals) for vals in self.sys_info["preferred_state"]] - async def save_preset(self, preset: BulbPreset): + async def save_preset(self, preset: LightPreset): """Save a setting preset. You can either construct a preset object manually, or pass an existing one diff --git a/kasa/smart/modules/color.py b/kasa/smart/modules/color.py index 979d4fec..88d02908 100644 --- a/kasa/smart/modules/color.py +++ b/kasa/smart/modules/color.py @@ -4,8 +4,8 @@ from __future__ import annotations from typing import TYPE_CHECKING -from ...bulb import HSV from ...feature import Feature +from ...interfaces.light import HSV from ..smartmodule import SmartModule if TYPE_CHECKING: diff --git a/kasa/smart/modules/colortemperature.py b/kasa/smart/modules/colortemperature.py index 88d5ea21..fa3b7412 100644 --- a/kasa/smart/modules/colortemperature.py +++ b/kasa/smart/modules/colortemperature.py @@ -5,8 +5,8 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING -from ...bulb import ColorTempRange from ...feature import Feature +from ...interfaces.light import ColorTempRange from ..smartmodule import SmartModule if TYPE_CHECKING: diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 122c943b..e7b45c8e 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -8,14 +8,14 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from ..aestransport import AesTransport -from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange from ..device import Device, WifiNetwork from ..device_type import DeviceType from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode -from ..fan import Fan from ..feature import Feature +from ..interfaces.fan import Fan +from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol @@ -39,7 +39,7 @@ WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. -class SmartDevice(Bulb, Fan, Device): +class SmartDevice(Light, Fan, Device): """Base class to represent a SMART protocol based device.""" def __init__( @@ -766,7 +766,7 @@ class SmartDevice(Bulb, Fan, Device): return await self.modules[Module.Brightness].set_brightness(brightness) @property - def presets(self) -> list[BulbPreset]: + def presets(self) -> list[LightPreset]: """Return a list of available bulb setting presets.""" return [] diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index acee8f74..19400c83 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,7 +7,7 @@ from voluptuous import ( Schema, ) -from kasa import Bulb, BulbPreset, Device, DeviceType, KasaException +from kasa import Device, DeviceType, KasaException, Light, LightPreset from kasa.iot import IotBulb, IotDimmer from kasa.smart import SmartDevice @@ -65,7 +65,7 @@ async def test_get_light_state(dev: IotBulb): @color_bulb @turn_on async def test_hsv(dev: Device, turn_on): - assert isinstance(dev, Bulb) + assert isinstance(dev, Light) await handle_turn_on(dev, turn_on) assert dev.is_color @@ -96,7 +96,7 @@ async def test_set_hsv_transition(dev: IotBulb, mocker): @color_bulb @turn_on -async def test_invalid_hsv(dev: Bulb, turn_on): +async def test_invalid_hsv(dev: Light, turn_on): await handle_turn_on(dev, turn_on) assert dev.is_color @@ -116,13 +116,13 @@ async def test_invalid_hsv(dev: Bulb, turn_on): @color_bulb @pytest.mark.skip("requires color feature") async def test_color_state_information(dev: Device): - assert isinstance(dev, Bulb) + assert isinstance(dev, Light) assert "HSV" in dev.state_information assert dev.state_information["HSV"] == dev.hsv @non_color_bulb -async def test_hsv_on_non_color(dev: Bulb): +async def test_hsv_on_non_color(dev: Light): assert not dev.is_color with pytest.raises(KasaException): @@ -134,7 +134,7 @@ async def test_hsv_on_non_color(dev: Bulb): @variable_temp @pytest.mark.skip("requires colortemp module") async def test_variable_temp_state_information(dev: Device): - assert isinstance(dev, Bulb) + assert isinstance(dev, Light) assert "Color temperature" in dev.state_information assert dev.state_information["Color temperature"] == dev.color_temp @@ -142,7 +142,7 @@ async def test_variable_temp_state_information(dev: Device): @variable_temp @turn_on async def test_try_set_colortemp(dev: Device, turn_on): - assert isinstance(dev, Bulb) + assert isinstance(dev, Light) await handle_turn_on(dev, turn_on) await dev.set_color_temp(2700) await dev.update() @@ -171,7 +171,7 @@ async def test_smart_temp_range(dev: SmartDevice): @variable_temp -async def test_out_of_range_temperature(dev: Bulb): +async def test_out_of_range_temperature(dev: Light): with pytest.raises(ValueError): await dev.set_color_temp(1000) with pytest.raises(ValueError): @@ -179,7 +179,7 @@ async def test_out_of_range_temperature(dev: Bulb): @non_variable_temp -async def test_non_variable_temp(dev: Bulb): +async def test_non_variable_temp(dev: Light): with pytest.raises(KasaException): await dev.set_color_temp(2700) @@ -193,7 +193,7 @@ async def test_non_variable_temp(dev: Bulb): @dimmable @turn_on async def test_dimmable_brightness(dev: Device, turn_on): - assert isinstance(dev, (Bulb, IotDimmer)) + assert isinstance(dev, (Light, IotDimmer)) await handle_turn_on(dev, turn_on) assert dev.is_dimmable @@ -230,7 +230,7 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): @dimmable -async def test_invalid_brightness(dev: Bulb): +async def test_invalid_brightness(dev: Light): assert dev.is_dimmable with pytest.raises(ValueError): @@ -241,7 +241,7 @@ async def test_invalid_brightness(dev: Bulb): @non_dimmable -async def test_non_dimmable(dev: Bulb): +async def test_non_dimmable(dev: Light): assert not dev.is_dimmable with pytest.raises(KasaException): @@ -291,7 +291,7 @@ async def test_modify_preset(dev: IotBulb, mocker): "saturation": 0, "color_temp": 0, } - preset = BulbPreset(**data) + preset = LightPreset(**data) assert preset.index == 0 assert preset.brightness == 10 @@ -305,7 +305,7 @@ async def test_modify_preset(dev: IotBulb, mocker): with pytest.raises(KasaException): await dev.save_preset( - BulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) + LightPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) ) @@ -314,11 +314,11 @@ async def test_modify_preset(dev: IotBulb, mocker): ("preset", "payload"), [ ( - BulbPreset(index=0, hue=0, brightness=1, saturation=0), + LightPreset(index=0, hue=0, brightness=1, saturation=0), {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, ), ( - BulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0), + LightPreset(index=0, brightness=1, id="testid", mode=2, custom=0), {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, ), ], diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index d0ed0c71..76ea1acf 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -25,6 +25,7 @@ def _get_subclasses(of_class): inspect.isclass(obj) and issubclass(obj, of_class) and module.__package__ != "kasa" + and module.__package__ != "kasa.interfaces" ): subclasses.add((module.__package__ + "." + name, obj)) return subclasses From 33d839866ec1d1d83a24399fcc0f723e4deb2c43 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 13 May 2024 17:34:44 +0100 Subject: [PATCH 09/11] Make Light and Fan a common module interface (#911) --- kasa/interfaces/fan.py | 4 +- kasa/interfaces/light.py | 16 +- kasa/iot/iotbulb.py | 58 ++---- kasa/iot/iotdevice.py | 6 + kasa/iot/iotdimmer.py | 27 +-- kasa/iot/iotlightstrip.py | 4 + kasa/iot/iotplug.py | 4 + kasa/iot/iotstrip.py | 4 + kasa/iot/modules/__init__.py | 2 + kasa/iot/modules/light.py | 188 ++++++++++++++++++ kasa/module.py | 8 +- kasa/smart/modules/__init__.py | 2 + kasa/smart/modules/brightness.py | 24 ++- kasa/smart/modules/light.py | 126 ++++++++++++ kasa/smart/smartdevice.py | 151 ++------------ kasa/tests/device_fixtures.py | 12 +- kasa/tests/smart/features/test_brightness.py | 6 +- kasa/tests/smart/modules/test_contact.py | 2 +- kasa/tests/smart/modules/test_fan.py | 20 +- kasa/tests/smart/modules/test_firmware.py | 2 +- kasa/tests/smart/modules/test_humidity.py | 2 +- kasa/tests/smart/modules/test_light_effect.py | 2 +- kasa/tests/smart/modules/test_temperature.py | 4 +- .../smart/modules/test_temperaturecontrol.py | 2 +- kasa/tests/smart/modules/test_waterleak.py | 2 +- kasa/tests/test_bulb.py | 97 +++++---- kasa/tests/test_common_modules.py | 38 +++- kasa/tests/test_dimmer.py | 20 +- kasa/tests/test_discovery.py | 8 +- kasa/tests/test_feature.py | 6 +- kasa/tests/test_lightstrip.py | 16 +- kasa/tests/test_smartdevice.py | 23 --- 32 files changed, 544 insertions(+), 342 deletions(-) create mode 100644 kasa/iot/modules/light.py create mode 100644 kasa/smart/modules/light.py diff --git a/kasa/interfaces/fan.py b/kasa/interfaces/fan.py index 767fe89f..89d8d82b 100644 --- a/kasa/interfaces/fan.py +++ b/kasa/interfaces/fan.py @@ -4,10 +4,10 @@ from __future__ import annotations from abc import ABC, abstractmethod -from ..device import Device +from ..module import Module -class Fan(Device, ABC): +class Fan(Module, ABC): """Interface for a Fan.""" @property diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py index 141be1fd..3a8805c1 100644 --- a/kasa/interfaces/light.py +++ b/kasa/interfaces/light.py @@ -7,7 +7,7 @@ from typing import NamedTuple, Optional from pydantic.v1 import BaseModel -from ..device import Device +from ..module import Module class ColorTempRange(NamedTuple): @@ -42,12 +42,13 @@ class LightPreset(BaseModel): mode: Optional[int] # noqa: UP007 -class Light(Device, ABC): +class Light(Module, ABC): """Base class for TP-Link Light.""" - def _raise_for_invalid_brightness(self, value): - if not isinstance(value, int) or not (0 <= value <= 100): - raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") + @property + @abstractmethod + def is_dimmable(self) -> bool: + """Whether the light supports brightness changes.""" @property @abstractmethod @@ -132,8 +133,3 @@ class Light(Device, ABC): :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - - @property - @abstractmethod - def presets(self) -> list[LightPreset]: - """Return a list of available bulb setting presets.""" diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index f6135fd1..e2d86043 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -11,12 +11,20 @@ from pydantic.v1 import BaseModel, Field, root_validator from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature -from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset +from ..interfaces.light import HSV, ColorTempRange, LightPreset from ..module import Module from ..protocol import BaseProtocol from .iotdevice import IotDevice, KasaException, requires_update -from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage +from .modules import ( + Antitheft, + Cloud, + Countdown, + Emeter, + Light, + Schedule, + Time, + Usage, +) class BehaviorMode(str, Enum): @@ -88,7 +96,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"} _LOGGER = logging.getLogger(__name__) -class IotBulb(IotDevice, Light): +class IotBulb(IotDevice): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -199,6 +207,10 @@ class IotBulb(IotDevice, Light): ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb + + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() self.add_module( Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule") ) @@ -210,39 +222,7 @@ class IotBulb(IotDevice, Light): self.add_module(Module.IotEmeter, Emeter(self, self.emeter_type)) self.add_module(Module.IotCountdown, Countdown(self, "countdown")) self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) - - async def _initialize_features(self): - await super()._initialize_features() - - if bool(self.sys_info["is_dimmable"]): # pragma: no branch - self._add_feature( - Feature( - device=self, - id="brightness", - name="Brightness", - attribute_getter="brightness", - attribute_setter="set_brightness", - minimum_value=1, - maximum_value=100, - type=Feature.Type.Number, - category=Feature.Category.Primary, - ) - ) - - if self.is_variable_color_temp: - self._add_feature( - Feature( - device=self, - id="color_temperature", - name="Color temperature", - container=self, - attribute_getter="color_temp", - attribute_setter="set_color_temp", - range_getter="valid_temperature_range", - category=Feature.Category.Primary, - type=Feature.Type.Number, - ) - ) + self.add_module(Module.Light, Light(self, "light")) @property # type: ignore @requires_update @@ -458,6 +438,10 @@ class IotBulb(IotDevice, Light): return await self.set_light_state(light_state, transition=transition) + def _raise_for_invalid_brightness(self, value): + if not isinstance(value, int) or not (0 <= value <= 100): + raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") + @property # type: ignore @requires_update def brightness(self) -> int: diff --git a/kasa/iot/iotdevice.py b/kasa/iot/iotdevice.py index e4c1bb13..f3ac5321 100755 --- a/kasa/iot/iotdevice.py +++ b/kasa/iot/iotdevice.py @@ -307,6 +307,9 @@ class IotDevice(Device): self._last_update = response self._set_sys_info(response["system"]["get_sysinfo"]) + if not self._modules: + await self._initialize_modules() + await self._modular_update(req) if not self._features: @@ -314,6 +317,9 @@ class IotDevice(Device): self._set_sys_info(self._last_update["system"]["get_sysinfo"]) + async def _initialize_modules(self): + """Initialize modules not added in init.""" + async def _initialize_features(self): self._add_feature( Feature( diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index fed9e7e7..d6f49c24 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -7,12 +7,11 @@ from typing import Any from ..device_type import DeviceType from ..deviceconfig import DeviceConfig -from ..feature import Feature from ..module import Module from ..protocol import BaseProtocol from .iotdevice import KasaException, requires_update from .iotplug import IotPlug -from .modules import AmbientLight, Motion +from .modules import AmbientLight, Light, Motion class ButtonAction(Enum): @@ -80,29 +79,15 @@ class IotDimmer(IotPlug): ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Dimmer + + async def _initialize_modules(self): + """Initialize modules.""" + await super()._initialize_modules() # TODO: need to be verified if it's okay to call these on HS220 w/o these # TODO: need to be figured out what's the best approach to detect support self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) - - async def _initialize_features(self): - await super()._initialize_features() - - if "brightness" in self.sys_info: # pragma: no branch - self._add_feature( - Feature( - device=self, - id="brightness", - name="Brightness", - attribute_getter="brightness", - attribute_setter="set_brightness", - minimum_value=1, - maximum_value=100, - unit="%", - type=Feature.Type.Number, - category=Feature.Category.Primary, - ) - ) + self.add_module(Module.Light, Light(self, "light")) @property # type: ignore @requires_update diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py index 7cdbe43b..6bc56258 100644 --- a/kasa/iot/iotlightstrip.py +++ b/kasa/iot/iotlightstrip.py @@ -56,6 +56,10 @@ class IotLightStrip(IotBulb): ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip + + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() self.add_module( Module.LightEffect, LightEffect(self, "smartlife.iot.lighting_effect"), diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py index 6aace4f8..07226178 100644 --- a/kasa/iot/iotplug.py +++ b/kasa/iot/iotplug.py @@ -53,6 +53,10 @@ class IotPlug(IotDevice): ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug + + async def _initialize_modules(self): + """Initialize modules.""" + await super()._initialize_modules() self.add_module(Module.IotSchedule, Schedule(self, "schedule")) self.add_module(Module.IotUsage, Usage(self, "schedule")) self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py index 4aa966e1..c4dcc57f 100755 --- a/kasa/iot/iotstrip.py +++ b/kasa/iot/iotstrip.py @@ -255,6 +255,10 @@ class IotStripPlug(IotPlug): self._set_sys_info(parent.sys_info) self._device_type = DeviceType.StripSocket self.protocol = parent.protocol # Must use the same connection as the parent + + async def _initialize_modules(self): + """Initialize modules not added in init.""" + await super()._initialize_modules() self.add_module("time", Time(self, "time")) async def update(self, update_children: bool = True): diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py index e0febfd4..2d6f6a01 100644 --- a/kasa/iot/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -6,6 +6,7 @@ from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter from .led import Led +from .light import Light from .lighteffect import LightEffect from .motion import Motion from .rulemodule import Rule, RuleModule @@ -20,6 +21,7 @@ __all__ = [ "Countdown", "Emeter", "Led", + "Light", "LightEffect", "Motion", "Rule", diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py new file mode 100644 index 00000000..89243a1b --- /dev/null +++ b/kasa/iot/modules/light.py @@ -0,0 +1,188 @@ +"""Implementation of brightness module.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from ...exceptions import KasaException +from ...feature import Feature +from ...interfaces.light import HSV, ColorTempRange +from ...interfaces.light import Light as LightInterface +from ..iotmodule import IotModule + +if TYPE_CHECKING: + from ..iotbulb import IotBulb + from ..iotdimmer import IotDimmer + + +BRIGHTNESS_MIN = 0 +BRIGHTNESS_MAX = 100 + + +class Light(IotModule, LightInterface): + """Implementation of brightness module.""" + + _device: IotBulb | IotDimmer + + def _initialize_features(self): + """Initialize features.""" + super()._initialize_features() + device = self._device + + if self._device.is_dimmable: + self._add_feature( + Feature( + device, + id="brightness", + name="Brightness", + container=self, + attribute_getter="brightness", + attribute_setter="set_brightness", + minimum_value=BRIGHTNESS_MIN, + maximum_value=BRIGHTNESS_MAX, + type=Feature.Type.Number, + category=Feature.Category.Primary, + ) + ) + if self._device.is_variable_color_temp: + self._add_feature( + Feature( + device=device, + id="color_temperature", + name="Color temperature", + container=self, + attribute_getter="color_temp", + attribute_setter="set_color_temp", + range_getter="valid_temperature_range", + category=Feature.Category.Primary, + type=Feature.Type.Number, + ) + ) + if self._device.is_color: + self._add_feature( + Feature( + device=device, + id="hsv", + name="HSV", + container=self, + attribute_getter="hsv", + attribute_setter="set_hsv", + # TODO proper type for setting hsv + type=Feature.Type.Unknown, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Brightness is contained in the main device info response. + return {} + + def _get_bulb_device(self) -> IotBulb | None: + if self._device.is_bulb or self._device.is_light_strip: + return cast("IotBulb", self._device) + return None + + @property # type: ignore + def is_dimmable(self) -> int: + """Whether the bulb supports brightness changes.""" + return self._device.is_dimmable + + @property # type: ignore + def brightness(self) -> int: + """Return the current brightness in percentage.""" + return self._device.brightness + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + return await self._device.set_brightness(brightness, transition=transition) + + @property + def is_color(self) -> bool: + """Whether the light supports color changes.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb.is_color + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb.is_variable_color_temp + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb.has_effects + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + raise KasaException("Light does not support color.") + return bulb.hsv + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + raise KasaException("Light does not support color.") + return await bulb.set_hsv(hue, saturation, value, transition=transition) + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return bulb.valid_temperature_range + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return bulb.color_temp + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: int | None = None + ) -> dict: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return await bulb.set_color_temp( + temp, brightness=brightness, transition=transition + ) diff --git a/kasa/module.py b/kasa/module.py index 55eeea18..9b541ce0 100644 --- a/kasa/module.py +++ b/kasa/module.py @@ -15,9 +15,8 @@ from .feature import Feature from .modulemapping import ModuleName if TYPE_CHECKING: + from . import interfaces from .device import Device - from .interfaces.led import Led - from .interfaces.lighteffect import LightEffect from .iot import modules as iot from .smart import modules as smart @@ -34,8 +33,9 @@ class Module(ABC): """ # Common Modules - LightEffect: Final[ModuleName[LightEffect]] = ModuleName("LightEffect") - Led: Final[ModuleName[Led]] = ModuleName("Led") + LightEffect: Final[ModuleName[interfaces.LightEffect]] = ModuleName("LightEffect") + Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") + Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") # IOT only Modules IotAmbientLight: Final[ModuleName[iot.AmbientLight]] = ModuleName("ambient") diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py index e119e067..b295bcb2 100644 --- a/kasa/smart/modules/__init__.py +++ b/kasa/smart/modules/__init__.py @@ -16,6 +16,7 @@ from .firmware import Firmware from .frostprotection import FrostProtection from .humiditysensor import HumiditySensor from .led import Led +from .light import Light from .lighteffect import LightEffect from .lighttransition import LightTransition from .reportmode import ReportMode @@ -41,6 +42,7 @@ __all__ = [ "Fan", "Firmware", "Cloud", + "Light", "LightEffect", "LightTransition", "ColorTemperature", diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index b0b58c07..fbd90808 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -2,16 +2,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from ...feature import Feature from ..smartmodule import SmartModule -if TYPE_CHECKING: - from ..smartdevice import SmartDevice - - -BRIGHTNESS_MIN = 1 +BRIGHTNESS_MIN = 0 BRIGHTNESS_MAX = 100 @@ -20,8 +14,11 @@ class Brightness(SmartModule): REQUIRED_COMPONENT = "brightness" - def __init__(self, device: SmartDevice, module: str): - super().__init__(device, module) + def _initialize_features(self): + """Initialize features.""" + super()._initialize_features() + + device = self._device self._add_feature( Feature( device, @@ -47,8 +44,11 @@ class Brightness(SmartModule): """Return current brightness.""" return self.data["brightness"] - async def set_brightness(self, brightness: int): - """Set the brightness.""" + async def set_brightness(self, brightness: int, *, transition: int | None = None): + """Set the brightness. A brightness value of 0 will turn off the light. + + Note, transition is not supported and will be ignored. + """ if not isinstance(brightness, int) or not ( BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX ): @@ -57,6 +57,8 @@ class Brightness(SmartModule): f"(valid range: {BRIGHTNESS_MIN}-{BRIGHTNESS_MAX}%)" ) + if brightness == 0: + return await self._device.turn_off() return await self.call("set_device_info", {"brightness": brightness}) async def _check_supported(self): diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py new file mode 100644 index 00000000..88d6486b --- /dev/null +++ b/kasa/smart/modules/light.py @@ -0,0 +1,126 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...exceptions import KasaException +from ...interfaces.light import HSV, ColorTempRange +from ...interfaces.light import Light as LightInterface +from ...module import Module +from ..smartmodule import SmartModule + + +class Light(SmartModule, LightInterface): + """Implementation of a light.""" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def is_color(self) -> bool: + """Whether the bulb supports color changes.""" + return Module.Color in self._device.modules + + @property + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + return Module.Brightness in self._device.modules + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + return Module.ColorTemperature in self._device.modules + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if not self.is_variable_color_temp: + raise KasaException("Color temperature not supported") + + return self._device.modules[Module.ColorTemperature].valid_temperature_range + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if not self.is_color: + raise KasaException("Bulb does not support color.") + + return self._device.modules[Module.Color].hsv + + @property + def color_temp(self) -> int: + """Whether the bulb supports color temperature changes.""" + if not self.is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + + return self._device.modules[Module.ColorTemperature].color_temp + + @property + def brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return self._device.modules[Module.Brightness].brightness + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value between 1 and 100 + :param int transition: transition in milliseconds. + """ + if not self.is_color: + raise KasaException("Bulb does not support color.") + + return await self._device.modules[Module.Color].set_hsv(hue, saturation, value) + + async def set_color_temp( + self, temp: int, *, brightness=None, transition: int | None = None + ) -> dict: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + if not self.is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + return await self._device.modules[Module.ColorTemperature].set_color_temp(temp) + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + if not self.is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return await self._device.modules[Module.Brightness].set_brightness(brightness) + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return Module.LightEffect in self._device.modules diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e7b45c8e..e1939c70 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -14,8 +14,7 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature -from ..interfaces.fan import Fan -from ..interfaces.light import HSV, ColorTempRange, Light, LightPreset +from ..interfaces.light import LightPreset from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol @@ -23,6 +22,7 @@ from .modules import ( Cloud, DeviceModule, Firmware, + Light, Time, ) from .smartmodule import SmartModule @@ -39,7 +39,7 @@ WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, Time, Firmware, Cloud] # Device must go last as the other interfaces also inherit Device # and python needs a consistent method resolution order. -class SmartDevice(Light, Fan, Device): +class SmartDevice(Device): """Base class to represent a SMART protocol based device.""" def __init__( @@ -231,6 +231,13 @@ class SmartDevice(Light, Fan, Device): if await module._check_supported(): self._modules[module.name] = module + if ( + Module.Brightness in self._modules + or Module.Color in self._modules + or Module.ColorTemperature in self._modules + ): + self._modules[Light.__name__] = Light(self, "light") + async def _initialize_features(self): """Initialize device features.""" self._add_feature( @@ -318,8 +325,11 @@ class SmartDevice(Light, Fan, Device): ) ) - for module in self._modules.values(): - module._initialize_features() + for module in self.modules.values(): + # Check if module features have already been initialized. + # i.e. when _exposes_child_modules is true + if not module._module_features: + module._initialize_features() for feat in module._module_features.values(): self._add_feature(feat) @@ -639,138 +649,7 @@ class SmartDevice(Light, Fan, Device): _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug - # Fan interface methods - - @property - def is_fan(self) -> bool: - """Return True if the device is a fan.""" - return Module.Fan in self.modules - - @property - def fan_speed_level(self) -> int: - """Return fan speed level.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - return self.modules[Module.Fan].fan_speed_level - - async def set_fan_speed_level(self, level: int): - """Set fan speed level.""" - if not self.is_fan: - raise KasaException("Device is not a Fan") - await self.modules[Module.Fan].set_fan_speed_level(level) - - # Bulb interface methods - - @property - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - return Module.Color in self.modules - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - return Module.Brightness in self.modules - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - return Module.ColorTemperature in self.modules - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if not self.is_variable_color_temp: - raise KasaException("Color temperature not supported") - - return self.modules[Module.ColorTemperature].valid_temperature_range - - @property - def hsv(self) -> HSV: - """Return the current HSV state of the bulb. - - :return: hue, saturation and value (degrees, %, %) - """ - if not self.is_color: - raise KasaException("Bulb does not support color.") - - return self.modules[Module.Color].hsv - - @property - def color_temp(self) -> int: - """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: - raise KasaException("Bulb does not support colortemp.") - - return self.modules[Module.ColorTemperature].color_temp - - @property - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return self.modules[Module.Brightness].brightness - - async def set_hsv( - self, - hue: int, - saturation: int, - value: int | None = None, - *, - transition: int | None = None, - ) -> dict: - """Set new HSV. - - Note, transition is not supported and will be ignored. - - :param int hue: hue in degrees - :param int saturation: saturation in percentage [0,100] - :param int value: value between 1 and 100 - :param int transition: transition in milliseconds. - """ - if not self.is_color: - raise KasaException("Bulb does not support color.") - - return await self.modules[Module.Color].set_hsv(hue, saturation, value) - - async def set_color_temp( - self, temp: int, *, brightness=None, transition: int | None = None - ) -> dict: - """Set the color temperature of the device in kelvin. - - Note, transition is not supported and will be ignored. - - :param int temp: The new color temperature, in Kelvin - :param int transition: transition in milliseconds. - """ - if not self.is_variable_color_temp: - raise KasaException("Bulb does not support colortemp.") - return await self.modules[Module.ColorTemperature].set_color_temp(temp) - - async def set_brightness( - self, brightness: int, *, transition: int | None = None - ) -> dict: - """Set the brightness in percentage. - - Note, transition is not supported and will be ignored. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ - if not self.is_dimmable: # pragma: no cover - raise KasaException("Bulb is not dimmable.") - - return await self.modules[Module.Brightness].set_brightness(brightness) - @property def presets(self) -> list[LightPreset]: """Return a list of available bulb setting presets.""" return [] - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return Module.LightEffect in self.modules diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 826465e5..e8fbeeec 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -203,14 +203,14 @@ wallswitch_iot = parametrize( "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} ) strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) -dimmer = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) -lightstrip = parametrize( +dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) +lightstrip_iot = parametrize( "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} ) # bulb types -dimmable = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) -non_dimmable = parametrize( +dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) +non_dimmable_iot = parametrize( "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} ) variable_temp = parametrize( @@ -292,12 +292,12 @@ device_iot = parametrize( def check_categories(): """Check that every fixture file is categorized.""" categorized_fixtures = set( - dimmer.args[1] + dimmer_iot.args[1] + strip.args[1] + plug.args[1] + bulb.args[1] + wallswitch.args[1] - + lightstrip.args[1] + + lightstrip_iot.args[1] + bulb_smart.args[1] + dimmers_smart.args[1] + hubs_smart.args[1] diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index 3c00a4d1..e3c3c530 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -2,7 +2,7 @@ import pytest from kasa.iot import IotDevice from kasa.smart import SmartDevice -from kasa.tests.conftest import dimmable, parametrize +from kasa.tests.conftest import dimmable_iot, parametrize brightness = parametrize("brightness smart", component_filter="brightness") @@ -16,7 +16,7 @@ async def test_brightness_component(dev: SmartDevice): assert "brightness" in dev._components # Test getting the value - feature = brightness._module_features["brightness"] + feature = dev.features["brightness"] assert isinstance(feature.value, int) assert feature.value > 1 and feature.value <= 100 @@ -32,7 +32,7 @@ async def test_brightness_component(dev: SmartDevice): await feature.set_value(feature.maximum_value + 10) -@dimmable +@dimmable_iot async def test_brightness_dimmable(dev: IotDevice): """Test brightness feature.""" assert isinstance(dev, IotDevice) diff --git a/kasa/tests/smart/modules/test_contact.py b/kasa/tests/smart/modules/test_contact.py index 88677c58..11440871 100644 --- a/kasa/tests/smart/modules/test_contact.py +++ b/kasa/tests/smart/modules/test_contact.py @@ -23,6 +23,6 @@ async def test_contact_features(dev: SmartDevice, feature, type): prop = getattr(contact, feature) assert isinstance(prop, type) - feat = contact._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 9597471b..b9627d9f 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -14,7 +14,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): fan = dev.modules.get(Module.Fan) assert fan - level_feature = fan._module_features["fan_speed_level"] + level_feature = dev.features["fan_speed_level"] assert ( level_feature.minimum_value <= level_feature.value @@ -38,7 +38,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" fan = dev.modules.get(Module.Fan) assert fan - sleep_feature = fan._module_features["fan_sleep_mode"] + sleep_feature = dev.features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) call = mocker.spy(fan, "call") @@ -52,7 +52,7 @@ async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): @fan -async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): +async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): """Test fan speed on device interface.""" assert isinstance(dev, SmartDevice) fan = dev.modules.get(Module.Fan) @@ -60,21 +60,21 @@ async def test_fan_interface(dev: SmartDevice, mocker: MockerFixture): device = fan._device assert device.is_fan - await device.set_fan_speed_level(1) + await fan.set_fan_speed_level(1) await dev.update() - assert device.fan_speed_level == 1 + assert fan.fan_speed_level == 1 assert device.is_on - await device.set_fan_speed_level(4) + await fan.set_fan_speed_level(4) await dev.update() - assert device.fan_speed_level == 4 + assert fan.fan_speed_level == 4 - await device.set_fan_speed_level(0) + await fan.set_fan_speed_level(0) await dev.update() assert not device.is_on with pytest.raises(ValueError): - await device.set_fan_speed_level(-1) + await fan.set_fan_speed_level(-1) with pytest.raises(ValueError): - await device.set_fan_speed_level(5) + await fan.set_fan_speed_level(5) diff --git a/kasa/tests/smart/modules/test_firmware.py b/kasa/tests/smart/modules/test_firmware.py index 8f329f70..b592041f 100644 --- a/kasa/tests/smart/modules/test_firmware.py +++ b/kasa/tests/smart/modules/test_firmware.py @@ -43,7 +43,7 @@ async def test_firmware_features( prop = getattr(fw, prop_name) assert isinstance(prop, type) - feat = fw._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_humidity.py b/kasa/tests/smart/modules/test_humidity.py index bf746f2b..790393e5 100644 --- a/kasa/tests/smart/modules/test_humidity.py +++ b/kasa/tests/smart/modules/test_humidity.py @@ -23,6 +23,6 @@ async def test_humidity_features(dev, feature, type): prop = getattr(humidity, feature) assert isinstance(prop, type) - feat = humidity._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_light_effect.py b/kasa/tests/smart/modules/test_light_effect.py index 56c3f096..ed691e66 100644 --- a/kasa/tests/smart/modules/test_light_effect.py +++ b/kasa/tests/smart/modules/test_light_effect.py @@ -20,7 +20,7 @@ async def test_light_effect(dev: Device, mocker: MockerFixture): light_effect = dev.modules.get(Module.LightEffect) assert isinstance(light_effect, LightEffect) - feature = light_effect._module_features["light_effect"] + feature = dev.features["light_effect"] assert feature.type == Feature.Type.Choice call = mocker.spy(light_effect, "call") diff --git a/kasa/tests/smart/modules/test_temperature.py b/kasa/tests/smart/modules/test_temperature.py index a7d20dac..c9685b9d 100644 --- a/kasa/tests/smart/modules/test_temperature.py +++ b/kasa/tests/smart/modules/test_temperature.py @@ -29,7 +29,7 @@ async def test_temperature_features(dev, feature, type): prop = getattr(temp_module, feature) assert isinstance(prop, type) - feat = temp_module._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) @@ -42,6 +42,6 @@ async def test_temperature_warning(dev): assert hasattr(temp_module, "temperature_warning") assert isinstance(temp_module.temperature_warning, bool) - feat = temp_module._module_features["temperature_warning"] + feat = dev.features["temperature_warning"] assert feat.value == temp_module.temperature_warning assert isinstance(feat.value, bool) diff --git a/kasa/tests/smart/modules/test_temperaturecontrol.py b/kasa/tests/smart/modules/test_temperaturecontrol.py index 4154cbf8..16e01ed2 100644 --- a/kasa/tests/smart/modules/test_temperaturecontrol.py +++ b/kasa/tests/smart/modules/test_temperaturecontrol.py @@ -28,7 +28,7 @@ async def test_temperature_control_features(dev, feature, type): prop = getattr(temp_module, feature) assert isinstance(prop, type) - feat = temp_module._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/smart/modules/test_waterleak.py b/kasa/tests/smart/modules/test_waterleak.py index aa589e44..61536193 100644 --- a/kasa/tests/smart/modules/test_waterleak.py +++ b/kasa/tests/smart/modules/test_waterleak.py @@ -25,7 +25,7 @@ async def test_waterleak_properties(dev, feature, prop_name, type): prop = getattr(waterleak, prop_name) assert isinstance(prop, type) - feat = waterleak._module_features[feature] + feat = dev.features[feature] assert feat.value == prop assert isinstance(feat.value, type) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 19400c83..5cfa25da 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -7,19 +7,18 @@ from voluptuous import ( Schema, ) -from kasa import Device, DeviceType, KasaException, Light, LightPreset +from kasa import Device, DeviceType, KasaException, LightPreset, Module from kasa.iot import IotBulb, IotDimmer -from kasa.smart import SmartDevice from .conftest import ( bulb, bulb_iot, color_bulb, color_bulb_iot, - dimmable, + dimmable_iot, handle_turn_on, non_color_bulb, - non_dimmable, + non_dimmable_iot, non_variable_temp, turn_on, variable_temp, @@ -65,19 +64,20 @@ async def test_get_light_state(dev: IotBulb): @color_bulb @turn_on async def test_hsv(dev: Device, turn_on): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - assert dev.is_color + assert light.is_color - hue, saturation, brightness = dev.hsv + hue, saturation, brightness = light.hsv assert 0 <= hue <= 360 assert 0 <= saturation <= 100 assert 0 <= brightness <= 100 - await dev.set_hsv(hue=1, saturation=1, value=1) + await light.set_hsv(hue=1, saturation=1, value=1) await dev.update() - hue, saturation, brightness = dev.hsv + hue, saturation, brightness = light.hsv assert hue == 1 assert saturation == 1 assert brightness == 1 @@ -96,57 +96,64 @@ async def test_set_hsv_transition(dev: IotBulb, mocker): @color_bulb @turn_on -async def test_invalid_hsv(dev: Light, turn_on): +async def test_invalid_hsv(dev: Device, turn_on): + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - assert dev.is_color + assert light.is_color for invalid_hue in [-1, 361, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type] + await light.set_hsv(invalid_hue, 0, 0) # type: ignore[arg-type] for invalid_saturation in [-1, 101, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type] + await light.set_hsv(0, invalid_saturation, 0) # type: ignore[arg-type] for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): - await dev.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type] + await light.set_hsv(0, 0, invalid_brightness) # type: ignore[arg-type] @color_bulb @pytest.mark.skip("requires color feature") async def test_color_state_information(dev: Device): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light assert "HSV" in dev.state_information - assert dev.state_information["HSV"] == dev.hsv + assert dev.state_information["HSV"] == light.hsv @non_color_bulb -async def test_hsv_on_non_color(dev: Light): - assert not dev.is_color +async def test_hsv_on_non_color(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert not light.is_color with pytest.raises(KasaException): - await dev.set_hsv(0, 0, 0) + await light.set_hsv(0, 0, 0) with pytest.raises(KasaException): - print(dev.hsv) + print(light.hsv) @variable_temp @pytest.mark.skip("requires colortemp module") async def test_variable_temp_state_information(dev: Device): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light assert "Color temperature" in dev.state_information - assert dev.state_information["Color temperature"] == dev.color_temp + assert dev.state_information["Color temperature"] == light.color_temp @variable_temp @turn_on async def test_try_set_colortemp(dev: Device, turn_on): - assert isinstance(dev, Light) + light = dev.modules.get(Module.Light) + assert light await handle_turn_on(dev, turn_on) - await dev.set_color_temp(2700) + await light.set_color_temp(2700) await dev.update() - assert dev.color_temp == 2700 + assert light.color_temp == 2700 @variable_temp_iot @@ -166,34 +173,40 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog): @variable_temp_smart -async def test_smart_temp_range(dev: SmartDevice): - assert dev.valid_temperature_range +async def test_smart_temp_range(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert light.valid_temperature_range @variable_temp -async def test_out_of_range_temperature(dev: Light): +async def test_out_of_range_temperature(dev: Device): + light = dev.modules.get(Module.Light) + assert light with pytest.raises(ValueError): - await dev.set_color_temp(1000) + await light.set_color_temp(1000) with pytest.raises(ValueError): - await dev.set_color_temp(10000) + await light.set_color_temp(10000) @non_variable_temp -async def test_non_variable_temp(dev: Light): +async def test_non_variable_temp(dev: Device): + light = dev.modules.get(Module.Light) + assert light with pytest.raises(KasaException): - await dev.set_color_temp(2700) + await light.set_color_temp(2700) with pytest.raises(KasaException): - print(dev.valid_temperature_range) + print(light.valid_temperature_range) with pytest.raises(KasaException): - print(dev.color_temp) + print(light.color_temp) -@dimmable +@dimmable_iot @turn_on -async def test_dimmable_brightness(dev: Device, turn_on): - assert isinstance(dev, (Light, IotDimmer)) +async def test_dimmable_brightness(dev: IotBulb, turn_on): + assert isinstance(dev, (IotBulb, IotDimmer)) await handle_turn_on(dev, turn_on) assert dev.is_dimmable @@ -229,8 +242,8 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): set_light_state.assert_called_with({"brightness": 10}, transition=1000) -@dimmable -async def test_invalid_brightness(dev: Light): +@dimmable_iot +async def test_invalid_brightness(dev: IotBulb): assert dev.is_dimmable with pytest.raises(ValueError): @@ -240,8 +253,8 @@ async def test_invalid_brightness(dev: Light): await dev.set_brightness(-100) -@non_dimmable -async def test_non_dimmable(dev: Light): +@non_dimmable_iot +async def test_non_dimmable(dev: IotBulb): assert not dev.is_dimmable with pytest.raises(KasaException): @@ -380,7 +393,7 @@ SYSINFO_SCHEMA_BULB = SYSINFO_SCHEMA.extend( @bulb -def test_device_type_bulb(dev): +def test_device_type_bulb(dev: Device): if dev.is_light_strip: pytest.skip("bulb has also lightstrips to test the api") assert dev.device_type == DeviceType.Bulb diff --git a/kasa/tests/test_common_modules.py b/kasa/tests/test_common_modules.py index 8f7def95..b07d8d98 100644 --- a/kasa/tests/test_common_modules.py +++ b/kasa/tests/test_common_modules.py @@ -3,7 +3,9 @@ from pytest_mock import MockerFixture from kasa import Device, Module from kasa.tests.device_fixtures import ( - lightstrip, + dimmable_iot, + dimmer_iot, + lightstrip_iot, parametrize, parametrize_combine, plug_iot, @@ -17,7 +19,12 @@ led = parametrize_combine([led_smart, plug_iot]) light_effect_smart = parametrize( "has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} ) -light_effect = parametrize_combine([light_effect_smart, lightstrip]) +light_effect = parametrize_combine([light_effect_smart, lightstrip_iot]) + +dimmable_smart = parametrize( + "dimmable smart", component_filter="brightness", protocol_filter={"SMART"} +) +dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) @led @@ -25,7 +32,7 @@ async def test_led_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" led_module = dev.modules.get(Module.Led) assert led_module - feat = led_module._module_features["led"] + feat = dev.features["led"] call = mocker.spy(led_module, "call") await led_module.set_led(True) @@ -52,7 +59,7 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): """Test fan speed feature.""" light_effect_module = dev.modules[Module.LightEffect] assert light_effect_module - feat = light_effect_module._module_features["light_effect"] + feat = dev.features["light_effect"] call = mocker.spy(light_effect_module, "call") effect_list = light_effect_module.effect_list @@ -93,3 +100,26 @@ async def test_light_effect_module(dev: Device, mocker: MockerFixture): with pytest.raises(ValueError): await light_effect_module.set_effect("foobar") assert call.call_count == 4 + + +@dimmable +async def test_light_brightness(dev: Device): + """Test brightness setter and getter.""" + assert isinstance(dev, Device) + light = dev.modules.get(Module.Light) + assert light + + # Test getting the value + feature = dev.features["brightness"] + assert feature.minimum_value == 0 + assert feature.maximum_value == 100 + + await light.set_brightness(10) + await dev.update() + assert light.brightness == 10 + + with pytest.raises(ValueError): + await light.set_brightness(feature.minimum_value - 10) + + with pytest.raises(ValueError): + await light.set_brightness(feature.maximum_value + 10) diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py index 6399ca4f..06150d39 100644 --- a/kasa/tests/test_dimmer.py +++ b/kasa/tests/test_dimmer.py @@ -3,10 +3,10 @@ import pytest from kasa import DeviceType from kasa.iot import IotDimmer -from .conftest import dimmer, handle_turn_on, turn_on +from .conftest import dimmer_iot, handle_turn_on, turn_on -@dimmer +@dimmer_iot @turn_on async def test_set_brightness(dev, turn_on): await handle_turn_on(dev, turn_on) @@ -22,7 +22,7 @@ async def test_set_brightness(dev, turn_on): assert dev.is_on == turn_on -@dimmer +@dimmer_iot @turn_on async def test_set_brightness_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -44,7 +44,7 @@ async def test_set_brightness_transition(dev, turn_on, mocker): assert dev.brightness == 1 -@dimmer +@dimmer_iot async def test_set_brightness_invalid(dev): for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): @@ -55,7 +55,7 @@ async def test_set_brightness_invalid(dev): await dev.set_brightness(1, transition=invalid_transition) -@dimmer +@dimmer_iot async def test_turn_on_transition(dev, mocker): query_helper = mocker.spy(IotDimmer, "_query_helper") original_brightness = dev.brightness @@ -72,7 +72,7 @@ async def test_turn_on_transition(dev, mocker): assert dev.brightness == original_brightness -@dimmer +@dimmer_iot async def test_turn_off_transition(dev, mocker): await handle_turn_on(dev, True) query_helper = mocker.spy(IotDimmer, "_query_helper") @@ -90,7 +90,7 @@ async def test_turn_off_transition(dev, mocker): ) -@dimmer +@dimmer_iot @turn_on async def test_set_dimmer_transition(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -108,7 +108,7 @@ async def test_set_dimmer_transition(dev, turn_on, mocker): assert dev.brightness == 99 -@dimmer +@dimmer_iot @turn_on async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): await handle_turn_on(dev, turn_on) @@ -127,7 +127,7 @@ async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): ) -@dimmer +@dimmer_iot async def test_set_dimmer_transition_invalid(dev): for invalid_brightness in [-1, 101, 0.5]: with pytest.raises(ValueError): @@ -138,6 +138,6 @@ async def test_set_dimmer_transition_invalid(dev): await dev.set_dimmer_transition(1, invalid_transition) -@dimmer +@dimmer_iot def test_device_type_dimmer(dev): assert dev.device_type == DeviceType.Dimmer diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index eb039144..2dea2004 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -26,8 +26,8 @@ from kasa.xortransport import XorEncryption from .conftest import ( bulb_iot, - dimmer, - lightstrip, + dimmer_iot, + lightstrip_iot, new_discovery, plug_iot, strip_iot, @@ -86,14 +86,14 @@ async def test_type_detection_strip(dev: Device): assert d.device_type == DeviceType.Strip -@dimmer +@dimmer_iot async def test_type_detection_dimmer(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_dimmer assert d.device_type == DeviceType.Dimmer -@lightstrip +@lightstrip_iot async def test_type_detection_lightstrip(dev: Device): d = Discover._get_device_class(dev._last_update)("localhost") assert d.is_light_strip diff --git a/kasa/tests/test_feature.py b/kasa/tests/test_feature.py index 101a21c0..0fb7156d 100644 --- a/kasa/tests/test_feature.py +++ b/kasa/tests/test_feature.py @@ -1,5 +1,6 @@ import logging import sys +from unittest.mock import patch import pytest from pytest_mock import MockerFixture @@ -180,11 +181,10 @@ async def test_feature_setters(dev: Device, mocker: MockerFixture): async def _test_features(dev): exceptions = [] - query = mocker.patch.object(dev.protocol, "query") for feat in dev.features.values(): - query.reset_mock() try: - await _test_feature(feat, query) + with patch.object(feat.device.protocol, "query") as query: + await _test_feature(feat, query) # we allow our own exceptions to avoid mocking valid responses except KasaException: pass diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py index f51f1805..41fdcde1 100644 --- a/kasa/tests/test_lightstrip.py +++ b/kasa/tests/test_lightstrip.py @@ -3,24 +3,24 @@ import pytest from kasa import DeviceType from kasa.iot import IotLightStrip -from .conftest import lightstrip +from .conftest import lightstrip_iot -@lightstrip +@lightstrip_iot async def test_lightstrip_length(dev: IotLightStrip): assert dev.is_light_strip assert dev.device_type == DeviceType.LightStrip assert dev.length == dev.sys_info["length"] -@lightstrip +@lightstrip_iot async def test_lightstrip_effect(dev: IotLightStrip): assert isinstance(dev.effect, dict) for k in ["brightness", "custom", "enable", "id", "name"]: assert k in dev.effect -@lightstrip +@lightstrip_iot async def test_effects_lightstrip_set_effect(dev: IotLightStrip): with pytest.raises(ValueError): await dev.set_effect("Not real") @@ -30,7 +30,7 @@ async def test_effects_lightstrip_set_effect(dev: IotLightStrip): assert dev.effect["name"] == "Candy Cane" -@lightstrip +@lightstrip_iot @pytest.mark.parametrize("brightness", [100, 50]) async def test_effects_lightstrip_set_effect_brightness( dev: IotLightStrip, brightness, mocker @@ -48,7 +48,7 @@ async def test_effects_lightstrip_set_effect_brightness( assert payload["brightness"] == brightness -@lightstrip +@lightstrip_iot @pytest.mark.parametrize("transition", [500, 1000]) async def test_effects_lightstrip_set_effect_transition( dev: IotLightStrip, transition, mocker @@ -66,12 +66,12 @@ async def test_effects_lightstrip_set_effect_transition( assert payload["transition"] == transition -@lightstrip +@lightstrip_iot async def test_effects_lightstrip_has_effects(dev: IotLightStrip): assert dev.has_effects is True assert dev.effect_list -@lightstrip +@lightstrip_iot def test_device_type_lightstrip(dev): assert dev.device_type == DeviceType.LightStrip diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index ed9e5721..c4a4685a 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -14,7 +14,6 @@ from kasa.exceptions import SmartErrorCode from kasa.smart import SmartDevice from .conftest import ( - bulb_smart, device_smart, get_device_for_fixture_protocol, ) @@ -159,28 +158,6 @@ async def test_get_modules(): assert module is None -@bulb_smart -async def test_smartdevice_brightness(dev: SmartDevice): - """Test brightness setter and getter.""" - assert isinstance(dev, SmartDevice) - assert "brightness" in dev._components - - # Test getting the value - feature = dev.features["brightness"] - assert feature.minimum_value == 1 - assert feature.maximum_value == 100 - - await dev.set_brightness(10) - await dev.update() - assert dev.brightness == 10 - - with pytest.raises(ValueError): - await dev.set_brightness(feature.minimum_value - 10) - - with pytest.raises(ValueError): - await dev.set_brightness(feature.maximum_value + 10) - - @device_smart async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixture): """Test is_cloud_connected property.""" From ef49f44eac89515d50b62f5af9fe842ab3210274 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 13 May 2024 18:52:08 +0100 Subject: [PATCH 10/11] Deprecate is_something attributes (#912) Deprecates the is_something attributes like is_bulb and is_dimmable in favour of the modular approach. --- kasa/device.py | 106 +++++++++++++-------------- kasa/iot/iotbulb.py | 20 ++--- kasa/iot/iotdimmer.py | 6 +- kasa/iot/modules/light.py | 30 +++++--- kasa/smart/smartdevice.py | 11 --- kasa/tests/smart/modules/test_fan.py | 1 - kasa/tests/test_bulb.py | 6 +- kasa/tests/test_device.py | 55 +++++++++++++- 8 files changed, 142 insertions(+), 93 deletions(-) diff --git a/kasa/device.py b/kasa/device.py index 8150352d..0f88f3a1 100644 --- a/kasa/device.py +++ b/kasa/device.py @@ -7,6 +7,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, Mapping, Sequence +from warnings import warn from .credentials import Credentials from .device_type import DeviceType @@ -208,61 +209,6 @@ class Device(ABC): def sys_info(self) -> dict[str, Any]: """Returns the device info.""" - @property - def is_bulb(self) -> bool: - """Return True if the device is a bulb.""" - return self.device_type == DeviceType.Bulb - - @property - def is_light_strip(self) -> bool: - """Return True if the device is a led strip.""" - return self.device_type == DeviceType.LightStrip - - @property - def is_plug(self) -> bool: - """Return True if the device is a plug.""" - return self.device_type == DeviceType.Plug - - @property - def is_wallswitch(self) -> bool: - """Return True if the device is a switch.""" - return self.device_type == DeviceType.WallSwitch - - @property - def is_strip(self) -> bool: - """Return True if the device is a strip.""" - return self.device_type == DeviceType.Strip - - @property - def is_strip_socket(self) -> bool: - """Return True if the device is a strip socket.""" - return self.device_type == DeviceType.StripSocket - - @property - def is_dimmer(self) -> bool: - """Return True if the device is a dimmer.""" - return self.device_type == DeviceType.Dimmer - - @property - def is_dimmable(self) -> bool: - """Return True if the device is dimmable.""" - return False - - @property - def is_fan(self) -> bool: - """Return True if the device is a fan.""" - return self.device_type == DeviceType.Fan - - @property - def is_variable_color_temp(self) -> bool: - """Return True if the device supports color temperature.""" - return False - - @property - def is_color(self) -> bool: - """Return True if the device supports color changes.""" - return False - def get_plug_by_name(self, name: str) -> Device: """Return child device for the given name.""" for p in self.children: @@ -383,3 +329,53 @@ class Device(ABC): if self._last_update is None: return f"<{self.device_type} at {self.host} - update() needed>" return f"<{self.device_type} at {self.host} - {self.alias} ({self.model})>" + + _deprecated_attributes = { + # is_type + "is_bulb": (Module.Light, lambda self: self.device_type == DeviceType.Bulb), + "is_dimmer": ( + Module.Light, + lambda self: self.device_type == DeviceType.Dimmer, + ), + "is_light_strip": ( + Module.LightEffect, + lambda self: self.device_type == DeviceType.LightStrip, + ), + "is_plug": (Module.Led, lambda self: self.device_type == DeviceType.Plug), + "is_wallswitch": ( + Module.Led, + lambda self: self.device_type == DeviceType.WallSwitch, + ), + "is_strip": (None, lambda self: self.device_type == DeviceType.Strip), + "is_strip_socket": ( + None, + lambda self: self.device_type == DeviceType.StripSocket, + ), # TODO + # is_light_function + "is_color": ( + Module.Light, + lambda self: Module.Light in self.modules + and self.modules[Module.Light].is_color, + ), + "is_dimmable": ( + Module.Light, + lambda self: Module.Light in self.modules + and self.modules[Module.Light].is_dimmable, + ), + "is_variable_color_temp": ( + Module.Light, + lambda self: Module.Light in self.modules + and self.modules[Module.Light].is_variable_color_temp, + ), + } + + def __getattr__(self, name) -> bool: + if name in self._deprecated_attributes: + module = self._deprecated_attributes[name][0] + func = self._deprecated_attributes[name][1] + msg = f"{name} is deprecated" + if module: + msg += f", use: {module} in device.modules instead" + warn(msg, DeprecationWarning, stacklevel=1) + return func(self) + raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py index e2d86043..51df94d1 100644 --- a/kasa/iot/iotbulb.py +++ b/kasa/iot/iotbulb.py @@ -226,21 +226,21 @@ class IotBulb(IotDevice): @property # type: ignore @requires_update - def is_color(self) -> bool: + def _is_color(self) -> bool: """Whether the bulb supports color changes.""" sys_info = self.sys_info return bool(sys_info["is_color"]) @property # type: ignore @requires_update - def is_dimmable(self) -> bool: + def _is_dimmable(self) -> bool: """Whether the bulb supports brightness changes.""" sys_info = self.sys_info return bool(sys_info["is_dimmable"]) @property # type: ignore @requires_update - def is_variable_color_temp(self) -> bool: + def _is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" sys_info = self.sys_info return bool(sys_info["is_variable_color_temp"]) @@ -252,7 +252,7 @@ class IotBulb(IotDevice): :return: White temperature range in Kelvin (minimum, maximum) """ - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Color temperature not supported") for model, temp_range in TPLINK_KELVIN.items(): @@ -352,7 +352,7 @@ class IotBulb(IotDevice): :return: hue, saturation and value (degrees, %, %) """ - if not self.is_color: + if not self._is_color: raise KasaException("Bulb does not support color.") light_state = cast(dict, self.light_state) @@ -379,7 +379,7 @@ class IotBulb(IotDevice): :param int value: value in percentage [0, 100] :param int transition: transition in milliseconds. """ - if not self.is_color: + if not self._is_color: raise KasaException("Bulb does not support color.") if not isinstance(hue, int) or not (0 <= hue <= 360): @@ -406,7 +406,7 @@ class IotBulb(IotDevice): @requires_update def color_temp(self) -> int: """Return color temperature of the device in kelvin.""" - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") light_state = self.light_state @@ -421,7 +421,7 @@ class IotBulb(IotDevice): :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - if not self.is_variable_color_temp: + if not self._is_variable_color_temp: raise KasaException("Bulb does not support colortemp.") valid_temperature_range = self.valid_temperature_range @@ -446,7 +446,7 @@ class IotBulb(IotDevice): @requires_update def brightness(self) -> int: """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover + if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") light_state = self.light_state @@ -461,7 +461,7 @@ class IotBulb(IotDevice): :param int brightness: brightness in percent :param int transition: transition in milliseconds. """ - if not self.is_dimmable: # pragma: no cover + if not self._is_dimmable: # pragma: no cover raise KasaException("Bulb is not dimmable.") self._raise_for_invalid_brightness(brightness) diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py index d6f49c24..ef99f749 100644 --- a/kasa/iot/iotdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -96,7 +96,7 @@ class IotDimmer(IotPlug): Will return a range between 0 - 100. """ - if not self.is_dimmable: + if not self._is_dimmable: raise KasaException("Device is not dimmable.") sys_info = self.sys_info @@ -109,7 +109,7 @@ class IotDimmer(IotPlug): :param int transition: transition duration in milliseconds. Using a transition will cause the dimmer to turn on. """ - if not self.is_dimmable: + if not self._is_dimmable: raise KasaException("Device is not dimmable.") if not isinstance(brightness, int): @@ -218,7 +218,7 @@ class IotDimmer(IotPlug): @property # type: ignore @requires_update - def is_dimmable(self) -> bool: + def _is_dimmable(self) -> bool: """Whether the switch supports brightness changes.""" sys_info = self.sys_info return "brightness" in sys_info diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py index 89243a1b..1bebf817 100644 --- a/kasa/iot/modules/light.py +++ b/kasa/iot/modules/light.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast +from ...device_type import DeviceType from ...exceptions import KasaException from ...feature import Feature from ...interfaces.light import HSV, ColorTempRange @@ -78,14 +79,19 @@ class Light(IotModule, LightInterface): return {} def _get_bulb_device(self) -> IotBulb | None: - if self._device.is_bulb or self._device.is_light_strip: + """For type checker this gets an IotBulb. + + IotDimmer is not a subclass of IotBulb and using isinstance + here at runtime would create a circular import. + """ + if self._device.device_type in {DeviceType.Bulb, DeviceType.LightStrip}: return cast("IotBulb", self._device) return None @property # type: ignore def is_dimmable(self) -> int: """Whether the bulb supports brightness changes.""" - return self._device.is_dimmable + return self._device._is_dimmable @property # type: ignore def brightness(self) -> int: @@ -107,14 +113,14 @@ class Light(IotModule, LightInterface): """Whether the light supports color changes.""" if (bulb := self._get_bulb_device()) is None: return False - return bulb.is_color + return bulb._is_color @property def is_variable_color_temp(self) -> bool: """Whether the bulb supports color temperature changes.""" if (bulb := self._get_bulb_device()) is None: return False - return bulb.is_variable_color_temp + return bulb._is_variable_color_temp @property def has_effects(self) -> bool: @@ -129,7 +135,7 @@ class Light(IotModule, LightInterface): :return: hue, saturation and value (degrees, %, %) """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + if (bulb := self._get_bulb_device()) is None or not bulb._is_color: raise KasaException("Light does not support color.") return bulb.hsv @@ -150,7 +156,7 @@ class Light(IotModule, LightInterface): :param int value: value in percentage [0, 100] :param int transition: transition in milliseconds. """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_color: + if (bulb := self._get_bulb_device()) is None or not bulb._is_color: raise KasaException("Light does not support color.") return await bulb.set_hsv(hue, saturation, value, transition=transition) @@ -160,14 +166,18 @@ class Light(IotModule, LightInterface): :return: White temperature range in Kelvin (minimum, maximum) """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") return bulb.valid_temperature_range @property def color_temp(self) -> int: """Whether the bulb supports color temperature changes.""" - if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") return bulb.color_temp @@ -181,7 +191,9 @@ class Light(IotModule, LightInterface): :param int temp: The new color temperature, in Kelvin :param int transition: transition in milliseconds. """ - if (bulb := self._get_bulb_device()) is None or not bulb.is_variable_color_temp: + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: raise KasaException("Light does not support colortemp.") return await bulb.set_color_temp( temp, brightness=brightness, transition=transition diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index e1939c70..e4260995 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -14,7 +14,6 @@ from ..deviceconfig import DeviceConfig from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..feature import Feature -from ..interfaces.light import LightPreset from ..module import Module from ..modulemapping import ModuleMapping, ModuleName from ..smartprotocol import SmartProtocol @@ -444,11 +443,6 @@ class SmartDevice(Device): """Return if the device has emeter.""" return Module.Energy in self.modules - @property - def is_dimmer(self) -> bool: - """Whether the device acts as a dimmer.""" - return self.is_dimmable - @property def is_on(self) -> bool: """Return true if the device is on.""" @@ -648,8 +642,3 @@ class SmartDevice(Device): return DeviceType.Thermostat _LOGGER.warning("Unknown device type, falling back to plug") return DeviceType.Plug - - @property - def presets(self) -> list[LightPreset]: - """Return a list of available bulb setting presets.""" - return [] diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index b9627d9f..e5e1ff72 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -58,7 +58,6 @@ async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): fan = dev.modules.get(Module.Fan) assert fan device = fan._device - assert device.is_fan await fan.set_fan_speed_level(1) await dev.update() diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py index 5cfa25da..2930db57 100644 --- a/kasa/tests/test_bulb.py +++ b/kasa/tests/test_bulb.py @@ -208,7 +208,7 @@ async def test_non_variable_temp(dev: Device): async def test_dimmable_brightness(dev: IotBulb, turn_on): assert isinstance(dev, (IotBulb, IotDimmer)) await handle_turn_on(dev, turn_on) - assert dev.is_dimmable + assert dev._is_dimmable await dev.set_brightness(50) await dev.update() @@ -244,7 +244,7 @@ async def test_dimmable_brightness_transition(dev: IotBulb, mocker): @dimmable_iot async def test_invalid_brightness(dev: IotBulb): - assert dev.is_dimmable + assert dev._is_dimmable with pytest.raises(ValueError): await dev.set_brightness(110) @@ -255,7 +255,7 @@ async def test_invalid_brightness(dev: IotBulb): @non_dimmable_iot async def test_non_dimmable(dev: IotBulb): - assert not dev.is_dimmable + assert not dev._is_dimmable with pytest.raises(KasaException): assert dev.brightness == 0 diff --git a/kasa/tests/test_device.py b/kasa/tests/test_device.py index 76ea1acf..6fd63d15 100644 --- a/kasa/tests/test_device.py +++ b/kasa/tests/test_device.py @@ -9,7 +9,7 @@ from unittest.mock import Mock, patch import pytest import kasa -from kasa import Credentials, Device, DeviceConfig +from kasa import Credentials, Device, DeviceConfig, DeviceType from kasa.iot import IotDevice from kasa.smart import SmartChildDevice, SmartDevice @@ -121,3 +121,56 @@ def test_deprecated_exceptions(exceptions_class, use_class): with pytest.deprecated_call(match=msg): getattr(kasa, exceptions_class) getattr(kasa, use_class.__name__) + + +deprecated_is_device_type = { + "is_bulb": DeviceType.Bulb, + "is_plug": DeviceType.Plug, + "is_dimmer": DeviceType.Dimmer, + "is_light_strip": DeviceType.LightStrip, + "is_wallswitch": DeviceType.WallSwitch, + "is_strip": DeviceType.Strip, + "is_strip_socket": DeviceType.StripSocket, +} +deprecated_is_light_function_smart_module = { + "is_color": "Color", + "is_dimmable": "Brightness", + "is_variable_color_temp": "ColorTemperature", +} + + +def test_deprecated_attributes(dev: SmartDevice): + """Test deprecated attributes on all devices.""" + tested_keys = set() + + def _test_attr(attribute): + tested_keys.add(attribute) + msg = f"{attribute} is deprecated" + if module := Device._deprecated_attributes[attribute][0]: + msg += f", use: {module} in device.modules instead" + with pytest.deprecated_call(match=msg): + val = getattr(dev, attribute) + return val + + for attribute in deprecated_is_device_type: + val = _test_attr(attribute) + expected_val = dev.device_type == deprecated_is_device_type[attribute] + assert val == expected_val + + for attribute in deprecated_is_light_function_smart_module: + val = _test_attr(attribute) + if isinstance(dev, SmartDevice): + expected_val = ( + deprecated_is_light_function_smart_module[attribute] in dev.modules + ) + elif hasattr(dev, f"_{attribute}"): + expected_val = getattr(dev, f"_{attribute}") + else: + expected_val = False + assert val == expected_val + + assert len(tested_keys) == len(Device._deprecated_attributes) + untested_keys = [ + key for key in Device._deprecated_attributes if key not in tested_keys + ] + assert len(untested_keys) == 0 From 67b5d7de83452240a2e277cd8d330e762c69e355 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 14 May 2024 08:38:21 +0100 Subject: [PATCH 11/11] Update cli to use common modules and remove iot specific cli testing (#913) --- kasa/cli.py | 74 +++++++++++++----------- kasa/tests/test_cli.py | 125 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 154 insertions(+), 45 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index d51679a2..d1b40a9e 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -27,7 +27,7 @@ from kasa import ( EncryptType, Feature, KasaException, - Light, + Module, UnsupportedDeviceError, ) from kasa.discover import DiscoveryResult @@ -859,18 +859,18 @@ async def usage(dev: Device, year, month, erase): @click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) @click.option("--transition", type=int, required=False) @pass_dev -async def brightness(dev: Light, brightness: int, transition: int): +async def brightness(dev: Device, brightness: int, transition: int): """Get or set brightness.""" - if not dev.is_dimmable: + if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: echo("This device does not support brightness.") return if brightness is None: - echo(f"Brightness: {dev.brightness}") - return dev.brightness + echo(f"Brightness: {light.brightness}") + return light.brightness else: echo(f"Setting brightness to {brightness}") - return await dev.set_brightness(brightness, transition=transition) + return await light.set_brightness(brightness, transition=transition) @cli.command() @@ -879,15 +879,15 @@ async def brightness(dev: Light, brightness: int, transition: int): ) @click.option("--transition", type=int, required=False) @pass_dev -async def temperature(dev: Light, temperature: int, transition: int): +async def temperature(dev: Device, temperature: int, transition: int): """Get or set color temperature.""" - if not dev.is_variable_color_temp: + if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: echo("Device does not support color temperature") return if temperature is None: - echo(f"Color temperature: {dev.color_temp}") - valid_temperature_range = dev.valid_temperature_range + echo(f"Color temperature: {light.color_temp}") + valid_temperature_range = light.valid_temperature_range if valid_temperature_range != (0, 0): echo("(min: {}, max: {})".format(*valid_temperature_range)) else: @@ -895,31 +895,34 @@ async def temperature(dev: Light, temperature: int, transition: int): "Temperature range unknown, please open a github issue" f" or a pull request for model '{dev.model}'" ) - return dev.valid_temperature_range + return light.valid_temperature_range else: echo(f"Setting color temperature to {temperature}") - return await dev.set_color_temp(temperature, transition=transition) + return await light.set_color_temp(temperature, transition=transition) @cli.command() @click.argument("effect", type=click.STRING, default=None, required=False) @click.pass_context @pass_dev -async def effect(dev, ctx, effect): +async def effect(dev: Device, ctx, effect): """Set an effect.""" - if not dev.has_effects: + if not (light_effect := dev.modules.get(Module.LightEffect)): echo("Device does not support effects") return if effect is None: raise click.BadArgumentUsage( - f"Setting an effect requires a named built-in effect: {dev.effect_list}", + "Setting an effect requires a named built-in effect: " + + f"{light_effect.effect_list}", ctx, ) - if effect not in dev.effect_list: - raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx) + if effect not in light_effect.effect_list: + raise click.BadArgumentUsage( + f"Effect must be one of: {light_effect.effect_list}", ctx + ) echo(f"Setting Effect: {effect}") - return await dev.set_effect(effect) + return await light_effect.set_effect(effect) @cli.command() @@ -929,33 +932,36 @@ async def effect(dev, ctx, effect): @click.option("--transition", type=int, required=False) @click.pass_context @pass_dev -async def hsv(dev, ctx, h, s, v, transition): +async def hsv(dev: Device, ctx, h, s, v, transition): """Get or set color in HSV.""" - if not dev.is_color: + if not (light := dev.modules.get(Module.Light)) or not light.is_color: echo("Device does not support colors") return - if h is None or s is None or v is None: - echo(f"Current HSV: {dev.hsv}") - return dev.hsv + if h is None and s is None and v is None: + echo(f"Current HSV: {light.hsv}") + return light.hsv elif s is None or v is None: raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) else: echo(f"Setting HSV: {h} {s} {v}") - return await dev.set_hsv(h, s, v, transition=transition) + return await light.set_hsv(h, s, v, transition=transition) @cli.command() @click.argument("state", type=bool, required=False) @pass_dev -async def led(dev, state): +async def led(dev: Device, state): """Get or set (Plug's) led state.""" + if not (led := dev.modules.get(Module.Led)): + echo("Device does not support led.") + return if state is not None: echo(f"Turning led to {state}") - return await dev.set_led(state) + return await led.set_led(state) else: - echo(f"LED state: {dev.led}") - return dev.led + echo(f"LED state: {led.led}") + return led.led @cli.command() @@ -975,8 +981,8 @@ async def time(dev): async def on(dev: Device, index: int, name: str, transition: int): """Turn the device on.""" if index is not None or name is not None: - if not dev.is_strip: - echo("Index and name are only for power strips!") + if not dev.children: + echo("Index and name are only for devices with children.") return if index is not None: @@ -996,8 +1002,8 @@ async def on(dev: Device, index: int, name: str, transition: int): async def off(dev: Device, index: int, name: str, transition: int): """Turn the device off.""" if index is not None or name is not None: - if not dev.is_strip: - echo("Index and name are only for power strips!") + if not dev.children: + echo("Index and name are only for devices with children.") return if index is not None: @@ -1017,8 +1023,8 @@ async def off(dev: Device, index: int, name: str, transition: int): async def toggle(dev: Device, index: int, name: str, transition: int): """Toggle the device on/off.""" if index is not None or name is not None: - if not dev.is_strip: - echo("Index and name are only for power strips!") + if not dev.children: + echo("Index and name are only for devices with children.") return if index is not None: diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index a438aa97..422010ba 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -13,6 +13,7 @@ from kasa import ( DeviceError, EmeterStatus, KasaException, + Module, UnsupportedDeviceError, ) from kasa.cli import ( @@ -21,11 +22,15 @@ from kasa.cli import ( brightness, cli, cmd_command, + effect, emeter, + hsv, + led, raw_command, reboot, state, sysinfo, + temperature, toggle, update_credentials, wifi, @@ -34,7 +39,6 @@ from kasa.discover import Discover, DiscoveryResult from kasa.iot import IotDevice from .conftest import ( - device_iot, device_smart, get_device_for_fixture_protocol, handle_turn_on, @@ -78,11 +82,10 @@ async def test_update_called_by_cli(dev, mocker, runner): update.assert_called() -@device_iot -async def test_sysinfo(dev, runner): +async def test_sysinfo(dev: Device, runner): res = await runner.invoke(sysinfo, obj=dev) assert "System info" in res.output - assert dev.alias in res.output + assert dev.model in res.output @turn_on @@ -108,7 +111,6 @@ async def test_toggle(dev, turn_on, runner): assert dev.is_on != turn_on -@device_iot async def test_alias(dev, runner): res = await runner.invoke(alias, obj=dev) assert f"Alias: {dev.alias}" in res.output @@ -308,15 +310,14 @@ async def test_emeter(dev: Device, mocker, runner): daily.assert_called_with(year=1900, month=12) -@device_iot -async def test_brightness(dev, runner): +async def test_brightness(dev: Device, runner): res = await runner.invoke(brightness, obj=dev) - if not dev.is_dimmable: + if not (light := dev.modules.get(Module.Light)) or not light.is_dimmable: assert "This device does not support brightness." in res.output return res = await runner.invoke(brightness, obj=dev) - assert f"Brightness: {dev.brightness}" in res.output + assert f"Brightness: {light.brightness}" in res.output res = await runner.invoke(brightness, ["12"], obj=dev) assert "Setting brightness" in res.output @@ -326,7 +327,110 @@ async def test_brightness(dev, runner): assert "Brightness: 12" in res.output -@device_iot +async def test_color_temperature(dev: Device, runner): + res = await runner.invoke(temperature, obj=dev) + if not (light := dev.modules.get(Module.Light)) or not light.is_variable_color_temp: + assert "Device does not support color temperature" in res.output + return + + res = await runner.invoke(temperature, obj=dev) + assert f"Color temperature: {light.color_temp}" in res.output + valid_range = light.valid_temperature_range + assert f"(min: {valid_range.min}, max: {valid_range.max})" in res.output + + val = int((valid_range.min + valid_range.max) / 2) + res = await runner.invoke(temperature, [str(val)], obj=dev) + assert "Setting color temperature to " in res.output + await dev.update() + + res = await runner.invoke(temperature, obj=dev) + assert f"Color temperature: {val}" in res.output + assert res.exit_code == 0 + + invalid_max = valid_range.max + 100 + # Lights that support the maximum range will not get past the click cli range check + # So can't be tested for the internal range check. + if invalid_max < 9000: + res = await runner.invoke(temperature, [str(invalid_max)], obj=dev) + assert res.exit_code == 1 + assert isinstance(res.exception, ValueError) + + res = await runner.invoke(temperature, [str(9100)], obj=dev) + assert res.exit_code == 2 + + +async def test_color_hsv(dev: Device, runner: CliRunner): + res = await runner.invoke(hsv, obj=dev) + if not (light := dev.modules.get(Module.Light)) or not light.is_color: + assert "Device does not support colors" in res.output + return + + res = await runner.invoke(hsv, obj=dev) + assert f"Current HSV: {light.hsv}" in res.output + + res = await runner.invoke(hsv, ["180", "50", "50"], obj=dev) + assert "Setting HSV: 180 50 50" in res.output + assert res.exit_code == 0 + await dev.update() + + res = await runner.invoke(hsv, ["180", "50"], obj=dev) + assert "Setting a color requires 3 values." in res.output + assert res.exit_code == 2 + + +async def test_light_effect(dev: Device, runner: CliRunner): + res = await runner.invoke(effect, obj=dev) + if not (light_effect := dev.modules.get(Module.LightEffect)): + assert "Device does not support effects" in res.output + return + + # Start off with a known state of off + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + await dev.update() + assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF + + res = await runner.invoke(effect, obj=dev) + msg = ( + "Setting an effect requires a named built-in effect: " + + f"{light_effect.effect_list}" + ) + assert msg in res.output + assert res.exit_code == 2 + + res = await runner.invoke(effect, [light_effect.effect_list[1]], obj=dev) + assert f"Setting Effect: {light_effect.effect_list[1]}" in res.output + assert res.exit_code == 0 + await dev.update() + assert light_effect.effect == light_effect.effect_list[1] + + res = await runner.invoke(effect, ["foobar"], obj=dev) + assert f"Effect must be one of: {light_effect.effect_list}" in res.output + assert res.exit_code == 2 + + +async def test_led(dev: Device, runner: CliRunner): + res = await runner.invoke(led, obj=dev) + if not (led_module := dev.modules.get(Module.Led)): + assert "Device does not support led" in res.output + return + + res = await runner.invoke(led, obj=dev) + assert f"LED state: {led_module.led}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(led, ["on"], obj=dev) + assert "Turning led to True" in res.output + assert res.exit_code == 0 + await dev.update() + assert led_module.led is True + + res = await runner.invoke(led, ["off"], obj=dev) + assert "Turning led to False" in res.output + assert res.exit_code == 0 + await dev.update() + assert led_module.led is False + + async def test_json_output(dev: Device, mocker, runner): """Test that the json output produces correct output.""" mocker.patch("kasa.Discover.discover", return_value={"127.0.0.1": dev}) @@ -375,7 +479,6 @@ async def test_credentials(discovery_mock, mocker, runner): assert "Username:foo Password:bar\n" in res.output -@device_iot async def test_without_device_type(dev, mocker, runner): """Test connecting without the device type.""" discovery_mock = mocker.patch(