From eff8db450def8185b600e53194c0c1801526d233 Mon Sep 17 00:00:00 2001
From: Steven B <51370195+sdb9696@users.noreply.github.com>
Date: Wed, 24 Apr 2024 19:17:49 +0100
Subject: [PATCH] Support for new ks240 fan/light wall switch (#839)
In order to support the ks240 which has children for the fan and light
components, this PR adds those modules at the parent level and hides the
children so it looks like a single device to consumers. It also decides
which modules not to take from the child because the child does not
support them even though it say it does. It does this for now via a
fixed list, e.g. `Time`, `Firmware` etc.
Also adds fixtures from two versions and corresponding tests.
---
README.md | 2 +-
SUPPORTED.md | 3 +
kasa/smart/modules/brightness.py | 4 +
kasa/smart/modules/fanmodule.py | 4 +
kasa/smart/modules/lighttransitionmodule.py | 12 +
kasa/smart/smartdevice.py | 30 +-
kasa/tests/device_fixtures.py | 1 +
kasa/tests/fakeprotocol_smart.py | 21 +-
.../fixtures/smart/KS240(US)_1.0_1.0.4.json | 482 ++++++++++++++++++
.../fixtures/smart/KS240(US)_1.0_1.0.5.json | 479 +++++++++++++++++
kasa/tests/smart/features/test_brightness.py | 4 +-
kasa/tests/smart/modules/test_fan.py | 14 +-
kasa/tests/test_smartdevice.py | 31 +-
13 files changed, 1067 insertions(+), 20 deletions(-)
create mode 100644 kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json
create mode 100644 kasa/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json
diff --git a/README.md b/README.md
index 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