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