diff --git a/README.md b/README.md index 6db63734..c17d80b5 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ The following devices have been tested and confirmed as working. If your device - **Plugs**: EP10, EP25\*, HS100\*\*, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M\*, KP401 - **Power Strips**: EP40, HS107, HS300, KP200, KP303, KP400 -- **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230 +- **Wall Switches**: ES20M, HS200, HS210, HS220, KP405, KS200M, KS205\*, KS220M, KS225\*, KS230, KS240\* - **Bulbs**: KL110, KL120, KL125, KL130, KL135, KL50, KL60, LB110 - **Light Strips**: KL400L5, KL420L5, KL430 - **Hubs**: KH100\* diff --git a/SUPPORTED.md b/SUPPORTED.md index 1587e966..c4957c65 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -88,6 +88,9 @@ Some newer Kasa devices require authentication. These are marked with *\* - **KS230** - Hardware: 1.0 (US) / Firmware: 1.0.14 +- **KS240** + - Hardware: 1.0 (US) / Firmware: 1.0.4\* + - Hardware: 1.0 (US) / Firmware: 1.0.5\* ### Bulbs diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py index af3026f6..b1209848 100644 --- a/kasa/smart/modules/brightness.py +++ b/kasa/smart/modules/brightness.py @@ -57,3 +57,7 @@ class Brightness(SmartModule): ) return await self.call("set_device_info", {"brightness": brightness}) + + async def _check_supported(self): + """Additional check to see if the module is supported by the device.""" + return "brightness" in self.data diff --git a/kasa/smart/modules/fanmodule.py b/kasa/smart/modules/fanmodule.py index 083f025c..13f35aea 100644 --- a/kasa/smart/modules/fanmodule.py +++ b/kasa/smart/modules/fanmodule.py @@ -68,3 +68,7 @@ class FanModule(SmartModule): async def set_sleep_mode(self, on: bool): """Set sleep mode.""" return await self.call("set_device_info", {"fan_sleep_mode_on": on}) + + async def _check_supported(self): + """Is the module available on this device.""" + return "fan_speed_level" in self.data diff --git a/kasa/smart/modules/lighttransitionmodule.py b/kasa/smart/modules/lighttransitionmodule.py index ebcb093c..e7da22ef 100644 --- a/kasa/smart/modules/lighttransitionmodule.py +++ b/kasa/smart/modules/lighttransitionmodule.py @@ -103,6 +103,8 @@ class LightTransitionModule(SmartModule): Available only from v2. """ + if "fade_on_time" in self._device.sys_info: + return self._device.sys_info["fade_on_time"] return self._turn_on["duration"] @property @@ -138,6 +140,8 @@ class LightTransitionModule(SmartModule): Available only from v2. """ + if "fade_off_time" in self._device.sys_info: + return self._device.sys_info["fade_off_time"] return self._turn_off["duration"] @property @@ -166,3 +170,11 @@ class LightTransitionModule(SmartModule): "set_on_off_gradually_info", {"off_state": {**self._turn_on, "duration": seconds}}, ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Some devices have the required info in the device info. + if "gradually_on_mode" in self._device.sys_info: + return {} + else: + return {self.QUERY_GETTER_NAME: None} diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py index 6393c61c..b325614b 100644 --- a/kasa/smart/smartdevice.py +++ b/kasa/smart/smartdevice.py @@ -22,6 +22,12 @@ _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: from .smartmodule import SmartModule +# List of modules that wall switches with children, i.e. ks240 report on +# the child but only work on the parent. See longer note below in _initialize_modules. +# This list should be updated when creating new modules that could have the +# same issue, homekit perhaps? +WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] # noqa: F405 + class SmartDevice(Device): """Base class to represent a SMART protocol based device.""" @@ -78,6 +84,9 @@ class SmartDevice(Device): @property def children(self) -> Sequence[SmartDevice]: """Return list of children.""" + # Wall switches with children report all modules on the parent only + if self.device_type == DeviceType.WallSwitch: + return [] return list(self._children.values()) def _try_get_response(self, responses: dict, request: str, default=None) -> dict: @@ -162,8 +171,23 @@ class SmartDevice(Device): """Initialize modules based on component negotiation response.""" from .smartmodule import SmartModule + # Some wall switches (like ks240) are internally presented as having child + # devices which report the child's components on the parent's sysinfo, even + # when they need to be accessed through the children. + # The logic below ensures that such devices report all but whitelisted, the + # child modules at the parent level to create an illusion of a single device. + if self._parent and self._parent.device_type == DeviceType.WallSwitch: + modules = self._parent.modules + skip_parent_only_modules = True + else: + modules = self.modules + skip_parent_only_modules = False + for mod in SmartModule.REGISTERED_MODULES.values(): _LOGGER.debug("%s requires %s", mod, mod.REQUIRED_COMPONENT) + + if skip_parent_only_modules and mod in WALL_SWITCH_PARENT_ONLY_MODULES: + continue if mod.REQUIRED_COMPONENT in self._components: _LOGGER.debug( "Found required %s, adding %s to modules.", @@ -171,8 +195,8 @@ class SmartDevice(Device): mod.__name__, ) module = mod(self, mod.REQUIRED_COMPONENT) - if await module._check_supported(): - self.modules[module.name] = module + if module.name not in modules and await module._check_supported(): + modules[module.name] = module async def _initialize_features(self): """Initialize device features.""" @@ -568,6 +592,8 @@ class SmartDevice(Device): return DeviceType.Plug if "light_strip" in components: return DeviceType.LightStrip + if "SWITCH" in device_type and "child_device" in components: + return DeviceType.WallSwitch if "dimmer_calibration" in components: return DeviceType.Dimmer if "brightness" in components: diff --git a/kasa/tests/device_fixtures.py b/kasa/tests/device_fixtures.py index 3cad6357..372c74a6 100644 --- a/kasa/tests/device_fixtures.py +++ b/kasa/tests/device_fixtures.py @@ -92,6 +92,7 @@ SWITCHES_IOT = { SWITCHES_SMART = { "KS205", "KS225", + "KS240", "S500D", "S505", } diff --git a/kasa/tests/fakeprotocol_smart.py b/kasa/tests/fakeprotocol_smart.py index dd9b1f16..b46f8f3d 100644 --- a/kasa/tests/fakeprotocol_smart.py +++ b/kasa/tests/fakeprotocol_smart.py @@ -139,10 +139,29 @@ class FakeSmartTransport(BaseTransport): # We only support get & set device info for now. if child_method == "get_device_info": - return {"result": info, "error_code": 0} + result = copy.deepcopy(info) + return {"result": result, "error_code": 0} elif child_method == "set_device_info": info.update(child_params) return {"error_code": 0} + elif ( + # FIXTURE_MISSING is for service calls not in place when + # SMART fixtures started to be generated + missing_result := self.FIXTURE_MISSING_MAP.get(child_method) + ) and missing_result[0] in self.components: + result = copy.deepcopy(missing_result[1]) + retval = {"result": result, "error_code": 0} + return retval + else: + # PARAMS error returned for KS240 when get_device_usage called + # on parent device. Could be any error code though. + # TODO: Try to figure out if there's a way to prevent the KS240 smartdevice + # calling the unsupported device in the first place. + retval = { + "error_code": SmartErrorCode.PARAMS_ERROR.value, + "method": child_method, + } + return retval raise NotImplementedError( "Method %s not implemented for children" % child_method diff --git a/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json new file mode 100644 index 00000000..2831e533 --- /dev/null +++ b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json @@ -0,0 +1,482 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 2 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000001" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "switch_ks240", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "device_id": "000000000000000000000000000000000000000000", + "device_on": false, + "fan_sleep_mode_on": false, + "fan_speed_level": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/Chicago", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + { + "avatar": "switch_ks240", + "bind_count": 1, + "brightness": 100, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "000000000000000000000000000000000000000001", + "device_on": false, + "fade_off_time": 1, + "fade_on_time": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "gradually_off_mode": 1, + "gradually_on_mode": 1, + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "lang": "en_US", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 0, + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/Chicago", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Chicago", + "rssi": -39, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1708643384 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-01-12", + "release_note": "Modifications and Bug Fixes:\n1. Improved time synchronization accuracy.\n2. Enhanced stability and performance.\n3. Fixed some minor bugs.", + "type": 2 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "auto", + "led_status": false, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "KS240", + "device_type": "SMART.KASASWITCH" + } + } +} diff --git a/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json new file mode 100644 index 00000000..6d14f7bf --- /dev/null +++ b/kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json @@ -0,0 +1,479 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 2 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS240(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000001" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "000000000000000000000000000000000000000000" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "switch_ks240", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "device_id": "000000000000000000000000000000000000000000", + "device_on": true, + "fan_sleep_mode_on": false, + "fan_speed_level": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "gc": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "", + "lo": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/New_York", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + { + "avatar": "switch_ks240", + "bind_count": 1, + "brightness": 100, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "000000000000000000000000000000000000000001", + "device_on": false, + "fade_off_time": 1, + "fade_on_time": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "gc": 1, + "gradually_off_mode": 1, + "gradually_on_mode": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "", + "led_off": 0, + "lo": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 0, + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/New_York", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/New_York", + "rssi": -46, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/New_York", + "time_diff": -300, + "timestamp": 1707863232 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "auto", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "KS240", + "device_type": "SMART.KASASWITCH", + "is_klap": false + } + } +} diff --git a/kasa/tests/smart/features/test_brightness.py b/kasa/tests/smart/features/test_brightness.py index c18dce97..d677725d 100644 --- a/kasa/tests/smart/features/test_brightness.py +++ b/kasa/tests/smart/features/test_brightness.py @@ -10,11 +10,13 @@ brightness = parametrize("brightness smart", component_filter="brightness") @brightness async def test_brightness_component(dev: SmartDevice): """Test brightness feature.""" + brightness = dev.modules.get("Brightness") + assert brightness assert isinstance(dev, SmartDevice) assert "brightness" in dev._components # Test getting the value - feature = dev.features["brightness"] + feature = brightness._module_features["brightness"] assert isinstance(feature.value, int) assert feature.value > 1 and feature.value <= 100 diff --git a/kasa/tests/smart/modules/test_fan.py b/kasa/tests/smart/modules/test_fan.py index 260fcf1a..559ffefe 100644 --- a/kasa/tests/smart/modules/test_fan.py +++ b/kasa/tests/smart/modules/test_fan.py @@ -1,18 +1,17 @@ from pytest_mock import MockerFixture from kasa import SmartDevice -from kasa.smart.modules import FanModule from kasa.tests.device_fixtures import parametrize -fan = parametrize( - "has fan", component_filter="fan_control", protocol_filter={"SMART.CHILD"} -) +fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) @fan async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): """Test fan speed feature.""" - fan: FanModule = dev.modules["FanModule"] + fan = dev.modules.get("FanModule") + assert fan + level_feature = fan._module_features["fan_speed_level"] assert ( level_feature.minimum_value @@ -22,7 +21,7 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): call = mocker.spy(fan, "call") await fan.set_fan_speed_level(3) - call.assert_called_with("set_device_info", {"fan_sleep_level": 3}) + call.assert_called_with("set_device_info", {"fan_speed_level": 3}) await dev.update() @@ -33,7 +32,8 @@ async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): @fan async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): """Test sleep mode feature.""" - fan: FanModule = dev.modules["FanModule"] + fan = dev.modules.get("FanModule") + assert fan sleep_feature = fan._module_features["fan_sleep_mode"] assert isinstance(sleep_feature.value, bool) diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py index 32bd3297..037edaf9 100644 --- a/kasa/tests/test_smartdevice.py +++ b/kasa/tests/test_smartdevice.py @@ -96,23 +96,29 @@ async def test_negotiate(dev: SmartDevice, mocker: MockerFixture): "get_child_device_list": None, } ) - assert len(dev.children) == dev.internal_state["get_child_device_list"]["sum"] + assert len(dev._children) == dev.internal_state["get_child_device_list"]["sum"] @device_smart async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): """Test that the regular update uses queries from all supported modules.""" - query = mocker.spy(dev.protocol, "query") - # We need to have some modules initialized by now assert dev.modules - await dev.update() - full_query: dict[str, Any] = {} + device_queries: dict[SmartDevice, dict[str, Any]] = {} for mod in dev.modules.values(): - full_query = {**full_query, **mod.query()} + device_queries.setdefault(mod._device, {}).update(mod.query()) - query.assert_called_with(full_query) + spies = {} + for dev in device_queries: + spies[dev] = mocker.spy(dev.protocol, "query") + + await dev.update() + for dev in device_queries: + if device_queries[dev]: + spies[dev].assert_called_with(device_queries[dev]) + else: + spies[dev].assert_not_called() @bulb_smart @@ -187,10 +193,19 @@ async def test_smartdevice_cloud_connection(dev: SmartDevice, mocker: MockerFixt "get_child_device_component_list" ] new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + + first_call = True + + def side_effect_func(*_, **__): + nonlocal first_call + resp = initial_response if first_call else last_update + first_call = False + return resp + with patch.object( new_dev.protocol, "query", - side_effect=[initial_response, last_update, last_update], + side_effect=side_effect_func, ): await new_dev.update() assert new_dev.is_cloud_connected is False