mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-07-11 01:42:05 +00:00
Add support for V2 Energy Monitoring for S515D (#1663)
Some checks are pending
CI / Perform Lint Checks (3.14) (push) Waiting to run
CI / Python 3.11 on macos-latest (push) Blocked by required conditions
CI / Python 3.12 on macos-latest (push) Blocked by required conditions
CI / Python 3.13 on macos-latest (push) Blocked by required conditions
CI / Python 3.14 on macos-latest (push) Blocked by required conditions
CI / Python 3.11 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.12 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.13 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.14 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.11 on windows-latest (push) Blocked by required conditions
CI / Python 3.12 on windows-latest (push) Blocked by required conditions
CI / Python 3.13 on windows-latest (push) Blocked by required conditions
CI / Python 3.14 on windows-latest (push) Blocked by required conditions
CodeQL Checks / Analyze (python) (push) Waiting to run
Some checks are pending
CI / Perform Lint Checks (3.14) (push) Waiting to run
CI / Python 3.11 on macos-latest (push) Blocked by required conditions
CI / Python 3.12 on macos-latest (push) Blocked by required conditions
CI / Python 3.13 on macos-latest (push) Blocked by required conditions
CI / Python 3.14 on macos-latest (push) Blocked by required conditions
CI / Python 3.11 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.12 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.13 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.14 on ubuntu-latest (push) Blocked by required conditions
CI / Python 3.11 on windows-latest (push) Blocked by required conditions
CI / Python 3.12 on windows-latest (push) Blocked by required conditions
CI / Python 3.13 on windows-latest (push) Blocked by required conditions
CI / Python 3.14 on windows-latest (push) Blocked by required conditions
CodeQL Checks / Analyze (python) (push) Waiting to run
Add support for S515D and other Energy Monitoring V2 devices that report current power via get_energy_usage instead of get_current_power, with fixture coverage and fallback tests.
This commit is contained in:
@@ -118,6 +118,7 @@ SWITCHES_SMART = {
|
||||
"S500D",
|
||||
"S505",
|
||||
"S505D",
|
||||
"S515D",
|
||||
"TS15",
|
||||
}
|
||||
SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART}
|
||||
@@ -126,7 +127,7 @@ STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M", "P210M", "P306", "P316M"}
|
||||
STRIPS = {*STRIPS_IOT, *STRIPS_SMART}
|
||||
|
||||
DIMMERS_IOT = {"ES20M", "HS220", "KS220", "KS220M", "KS230", "KP405"}
|
||||
DIMMERS_SMART = {"HS220", "KS225", "S500D", "P135"}
|
||||
DIMMERS_SMART = {"HS220", "KS225", "S500D", "S505D", "S515D", "P135"}
|
||||
DIMMERS = {
|
||||
*DIMMERS_IOT,
|
||||
*DIMMERS_SMART,
|
||||
@@ -150,7 +151,7 @@ THERMOSTATS_SMART = {"KE100"}
|
||||
VACUUMS_SMART = {"RV20"}
|
||||
|
||||
WITH_EMETER_IOT = {"EP25", "HS110", "HS300", "KP115", "KP125", *BULBS_IOT}
|
||||
WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"}
|
||||
WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M", "S515D"}
|
||||
WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART}
|
||||
|
||||
DIMMABLE = {*BULBS, *DIMMERS}
|
||||
|
||||
688
tests/fixtures/smart/S515D(US)_1.6_1.0.4.json
vendored
Normal file
688
tests/fixtures/smart/S515D(US)_1.6_1.0.4.json
vendored
Normal file
@@ -0,0 +1,688 @@
|
||||
{
|
||||
"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": "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": 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": "localSmart",
|
||||
"ver_code": 1
|
||||
},
|
||||
{
|
||||
"id": "matter",
|
||||
"ver_code": 2
|
||||
},
|
||||
{
|
||||
"id": "dimmer_custom_action",
|
||||
"ver_code": 1
|
||||
},
|
||||
{
|
||||
"id": "energy_monitoring",
|
||||
"ver_code": 2
|
||||
},
|
||||
{
|
||||
"id": "current_protection",
|
||||
"ver_code": 1
|
||||
},
|
||||
{
|
||||
"id": "satellite_check",
|
||||
"ver_code": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"discovery_result": {
|
||||
"error_code": 0,
|
||||
"result": {
|
||||
"device_id": "00000000000000000000000000000000",
|
||||
"device_model": "S515D(US)",
|
||||
"device_type": "SMART.TAPOSWITCH",
|
||||
"factory_default": false,
|
||||
"ip": "127.0.0.123",
|
||||
"is_support_iot_cloud": true,
|
||||
"mac": "CC-BA-BD-00-00-00",
|
||||
"mgt_encrypt_schm": {
|
||||
"encrypt_type": "KLAP",
|
||||
"http_port": 80,
|
||||
"is_support_https": false,
|
||||
"lv": 2
|
||||
},
|
||||
"obd_src": "tplink",
|
||||
"owner": "00000000000000000000000000000000",
|
||||
"protocol_version": 1
|
||||
}
|
||||
},
|
||||
"get_antitheft_rules": {
|
||||
"antitheft_rule_max_count": 1,
|
||||
"enable": false,
|
||||
"rule_list": []
|
||||
},
|
||||
"get_auto_update_info": {
|
||||
"enable": true,
|
||||
"random_range": 120,
|
||||
"time": 180
|
||||
},
|
||||
"get_connect_cloud_state": {
|
||||
"status": 0
|
||||
},
|
||||
"get_countdown_rules": {
|
||||
"countdown_rule_max_count": 1,
|
||||
"enable": false,
|
||||
"rule_list": []
|
||||
},
|
||||
"get_device_info": {
|
||||
"avatar": "switch",
|
||||
"brightness": 34,
|
||||
"dc_state": 0,
|
||||
"default_states": {
|
||||
"re_power_type": "always_off",
|
||||
"re_power_type_capability": [
|
||||
"last_states",
|
||||
"always_on",
|
||||
"always_off"
|
||||
],
|
||||
"type": "last_states"
|
||||
},
|
||||
"device_id": "0000000000000000000000000000000000000000",
|
||||
"device_on": false,
|
||||
"fw_id": "00000000000000000000000000000000",
|
||||
"fw_ver": "1.0.4 Build 240805 Rel.204647",
|
||||
"has_set_location_info": false,
|
||||
"hw_id": "00000000000000000000000000000000",
|
||||
"hw_ver": "1.6",
|
||||
"ip": "127.0.0.123",
|
||||
"lang": "en_US",
|
||||
"latitude": 0,
|
||||
"longitude": 0,
|
||||
"mac": "CC-BA-BD-00-00-00",
|
||||
"model": "S515D",
|
||||
"nickname": "I01BU0tFRF9OQU1FIw==",
|
||||
"oem_id": "00000000000000000000000000000000",
|
||||
"on_time": 0,
|
||||
"overcurrent_status": "normal",
|
||||
"region": "America/Los_Angeles",
|
||||
"rssi": -49,
|
||||
"signal_level": 3,
|
||||
"specs": "",
|
||||
"ssid": "I01BU0tFRF9TU0lEIw==",
|
||||
"time_diff": -480,
|
||||
"type": "SMART.TAPOSWITCH"
|
||||
},
|
||||
"get_device_time": {
|
||||
"region": "America/Los_Angeles",
|
||||
"time_diff": -480,
|
||||
"timestamp": 1771710913
|
||||
},
|
||||
"get_device_usage": {
|
||||
"power_usage": {
|
||||
"past30": 44,
|
||||
"past7": 31,
|
||||
"today": 1
|
||||
},
|
||||
"saved_power": {
|
||||
"past30": 266,
|
||||
"past7": 179,
|
||||
"today": 0
|
||||
},
|
||||
"time_usage": {
|
||||
"past30": 310,
|
||||
"past7": 210,
|
||||
"today": 1
|
||||
}
|
||||
},
|
||||
"get_electricity_price_config": {
|
||||
"constant_price": 0,
|
||||
"time_of_use_config": {
|
||||
"summer": {
|
||||
"midpeak": 0,
|
||||
"offpeak": 0,
|
||||
"onpeak": 0,
|
||||
"period": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"weekday_config": [
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"weekend_config": [
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"winter": {
|
||||
"midpeak": 0,
|
||||
"offpeak": 0,
|
||||
"onpeak": 0,
|
||||
"period": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"weekday_config": [
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"weekend_config": [
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "constant"
|
||||
},
|
||||
"get_emeter_data": {
|
||||
"current_ma": 0,
|
||||
"energy_wh": 44,
|
||||
"power_mw": 0,
|
||||
"voltage_mv": 117232
|
||||
},
|
||||
"get_emeter_vgain_igain": {
|
||||
"igain": 3698,
|
||||
"vgain": 122959
|
||||
},
|
||||
"get_energy_usage": {
|
||||
"current_power": 0,
|
||||
"electricity_charge": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"local_time": "2026-02-21 13:55:15",
|
||||
"month_energy": 44,
|
||||
"month_runtime": 310,
|
||||
"today_energy": 1,
|
||||
"today_runtime": 1
|
||||
},
|
||||
"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.0.4 Build 240805 Rel.204647",
|
||||
"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": "always",
|
||||
"led_status": true,
|
||||
"night_mode": {
|
||||
"end_time": 420,
|
||||
"night_mode_type": "custom",
|
||||
"start_time": 1320
|
||||
}
|
||||
},
|
||||
"get_matter_setup_info": {
|
||||
"setup_code": "00000000000",
|
||||
"setup_payload": "00:0000000000000000000"
|
||||
},
|
||||
"get_next_event": {},
|
||||
"get_on_off_gradually_info": {
|
||||
"off_state": {
|
||||
"duration": 1,
|
||||
"enable": true,
|
||||
"max_duration": 60
|
||||
},
|
||||
"on_state": {
|
||||
"duration": 1,
|
||||
"enable": true,
|
||||
"max_duration": 60
|
||||
}
|
||||
},
|
||||
"get_preset_rules": {
|
||||
"brightness": [
|
||||
100,
|
||||
75,
|
||||
50,
|
||||
25,
|
||||
1
|
||||
]
|
||||
},
|
||||
"get_schedule_rules": {
|
||||
"enable": false,
|
||||
"rule_list": [],
|
||||
"schedule_rule_max_count": 32,
|
||||
"start_index": 0,
|
||||
"sum": 0
|
||||
},
|
||||
"get_wireless_scan_info": {
|
||||
"ap_list": [
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 3,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 2,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 2,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 2,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 2,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 0,
|
||||
"key_type": "none",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
},
|
||||
{
|
||||
"bssid": "000000000000",
|
||||
"channel": 0,
|
||||
"cipher_type": 2,
|
||||
"key_type": "wpa2_psk",
|
||||
"signal_level": 1,
|
||||
"ssid": "I01BU0tFRF9TU0lEIw=="
|
||||
}
|
||||
],
|
||||
"start_index": 0,
|
||||
"sum": 26,
|
||||
"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": "matter",
|
||||
"ver_code": 2
|
||||
},
|
||||
{
|
||||
"id": "iot_cloud",
|
||||
"ver_code": 1
|
||||
},
|
||||
{
|
||||
"id": "inherit",
|
||||
"ver_code": 1
|
||||
},
|
||||
{
|
||||
"id": "firmware",
|
||||
"ver_code": 2
|
||||
},
|
||||
{
|
||||
"id": "satellite_check",
|
||||
"ver_code": 1
|
||||
}
|
||||
],
|
||||
"extra_info": {
|
||||
"device_model": "S515D",
|
||||
"device_type": "SMART.TAPOSWITCH",
|
||||
"is_klap": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,83 @@ from kasa.exceptions import SmartErrorCode
|
||||
from kasa.interfaces.energy import Energy
|
||||
from kasa.smart import SmartDevice
|
||||
from kasa.smart.modules import Energy as SmartEnergyModule
|
||||
from tests.conftest import has_emeter_smart
|
||||
|
||||
from ...device_fixtures import has_emeter_smart, parametrize
|
||||
|
||||
s515d_smart = parametrize(
|
||||
"s515d smart",
|
||||
model_filter={"S515D(US)_1.6_1.0.4"},
|
||||
protocol_filter={"SMART"},
|
||||
)
|
||||
p110_v1_smart = parametrize(
|
||||
"p110 v1 smart",
|
||||
model_filter={"P110(EU)_1.0_1.0.7"},
|
||||
protocol_filter={"SMART"},
|
||||
)
|
||||
|
||||
|
||||
@has_emeter_smart
|
||||
async def test_supported(dev: SmartDevice) -> None:
|
||||
def _get_energy_module(dev: SmartDevice) -> SmartEnergyModule:
|
||||
energy_module = dev.modules.get(Module.Energy)
|
||||
if not energy_module:
|
||||
pytest.skip(f"Energy module not supported for {dev}.")
|
||||
|
||||
assert isinstance(energy_module, SmartEnergyModule)
|
||||
return energy_module
|
||||
|
||||
|
||||
def _get_v2_energy_module(dev: SmartDevice) -> SmartEnergyModule:
|
||||
energy_module = _get_energy_module(dev)
|
||||
if energy_module.supported_version <= 1:
|
||||
pytest.skip("Only applicable for energy v2+ fixtures.")
|
||||
|
||||
return energy_module
|
||||
|
||||
|
||||
def _energy_usage(current_power: int | None = None) -> dict[str, int]:
|
||||
energy_usage = {
|
||||
"month_energy": 0,
|
||||
"today_energy": 0,
|
||||
}
|
||||
if current_power is not None:
|
||||
energy_usage["current_power"] = current_power
|
||||
|
||||
return energy_usage
|
||||
|
||||
|
||||
def _copy_last_update(dev: SmartDevice, *remove_keys: str) -> dict[str, object]:
|
||||
"""Copy the cached update response, removing keys from the cache if needed."""
|
||||
response = copy.deepcopy(dev._last_update)
|
||||
for key in remove_keys:
|
||||
response.pop(key, None)
|
||||
dev._last_update.pop(key, None)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _device_error(method: str, error_code: SmartErrorCode) -> DeviceError:
|
||||
return DeviceError(method, error_code=error_code)
|
||||
|
||||
|
||||
def _mock_query(responses: dict[str, object], calls: list[str]):
|
||||
async def query(request: dict[str, dict | None]) -> dict[str, object]:
|
||||
method = next(iter(request))
|
||||
calls.append(method)
|
||||
|
||||
if method not in responses:
|
||||
raise AssertionError(f"Unexpected request: {request}")
|
||||
|
||||
response = responses[method]
|
||||
if isinstance(response, DeviceError):
|
||||
raise response
|
||||
|
||||
return {method: response}
|
||||
|
||||
return query
|
||||
|
||||
|
||||
@has_emeter_smart
|
||||
async def test_supported(dev: SmartDevice) -> None:
|
||||
energy_module = _get_energy_module(dev)
|
||||
assert energy_module.supports(Energy.ModuleFeature.CONSUMPTION_TOTAL) is False
|
||||
assert energy_module.supports(Energy.ModuleFeature.PERIODIC_STATS) is False
|
||||
if energy_module.supported_version < 2:
|
||||
@@ -35,11 +102,8 @@ async def test_get_energy_usage_error(
|
||||
"""Test errors on get_energy_usage."""
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
energy_module = dev.modules.get(Module.Energy)
|
||||
if not energy_module:
|
||||
pytest.skip(f"Energy module not supported for {dev}.")
|
||||
|
||||
version = dev._components["energy_monitoring"]
|
||||
energy_module = _get_energy_module(dev)
|
||||
version = energy_module.supported_version
|
||||
|
||||
expected_raise = does_not_raise() if version > 1 else pytest.raises(DeviceError)
|
||||
if version > 1:
|
||||
@@ -54,13 +118,10 @@ async def test_get_energy_usage_error(
|
||||
assert energy_module.consumption_today is not None
|
||||
assert energy_module.consumption_this_month is not None
|
||||
|
||||
last_update = copy.deepcopy(dev._last_update)
|
||||
resp = copy.deepcopy(last_update)
|
||||
resp = _copy_last_update(dev)
|
||||
|
||||
if ed := resp.get("get_emeter_data"):
|
||||
ed["power_mw"] = 2002
|
||||
if cp := resp.get("get_current_power"):
|
||||
cp["current_power"] = 2.002
|
||||
if version > 1:
|
||||
resp["get_emeter_data"] = {"power_mw": 2002}
|
||||
resp["get_energy_usage"] = SmartErrorCode.JSON_DECODE_FAIL_ERROR
|
||||
|
||||
# version 1 only has get_energy_usage so module should raise an error if
|
||||
@@ -82,28 +143,284 @@ async def test_get_energy_usage_error(
|
||||
if version > 1:
|
||||
assert msg in caplog.text
|
||||
|
||||
# Now test with no get_emeter_data
|
||||
# This may not be valid scenario but we have a fallback to get_current_power
|
||||
# just in case that should be tested.
|
||||
caplog.clear()
|
||||
resp = copy.deepcopy(last_update)
|
||||
|
||||
if cp := resp.get("get_current_power"):
|
||||
cp["current_power"] = 2.002
|
||||
resp["get_energy_usage"] = SmartErrorCode.JSON_DECODE_FAIL_ERROR
|
||||
|
||||
# Remove get_emeter_data from the response and from the device which will
|
||||
# remember it otherwise.
|
||||
resp.pop("get_emeter_data", None)
|
||||
dev._last_update.pop("get_emeter_data", None)
|
||||
@has_emeter_smart
|
||||
@pytest.mark.parametrize(
|
||||
("remove_keys", "response_updates", "expected_power"),
|
||||
[
|
||||
pytest.param(
|
||||
(),
|
||||
{
|
||||
"get_emeter_data": {"power_mw": 3003},
|
||||
"get_energy_usage": _energy_usage(current_power=2002),
|
||||
"get_current_power": {"current_power": 1.001},
|
||||
},
|
||||
3.003,
|
||||
id="prefers_get_emeter_data",
|
||||
),
|
||||
pytest.param(
|
||||
("get_emeter_data",),
|
||||
{
|
||||
"get_energy_usage": _energy_usage(current_power=2002),
|
||||
"get_current_power": {"current_power": 1.001},
|
||||
},
|
||||
2.002,
|
||||
id="falls_back_to_get_energy_usage",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_v2_current_power_source_precedence(
|
||||
dev: SmartDevice,
|
||||
remove_keys: tuple[str, ...],
|
||||
response_updates: dict[str, object],
|
||||
expected_power: float,
|
||||
) -> None:
|
||||
"""Prefer higher precision current power sources for v2 devices."""
|
||||
energy_module = _get_v2_energy_module(dev)
|
||||
resp = _copy_last_update(dev, *remove_keys)
|
||||
resp.update(response_updates)
|
||||
|
||||
with patch.object(dev.protocol, "query", return_value=resp):
|
||||
await dev.update()
|
||||
|
||||
with expected_raise:
|
||||
assert energy_module.current_consumption == expected_power
|
||||
assert energy_module.status.power == expected_power
|
||||
|
||||
|
||||
@has_emeter_smart
|
||||
@pytest.mark.parametrize(
|
||||
("response_updates", "expected_power", "expect_energy_usage_removed"),
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"get_current_power": {"current_power": 2.002},
|
||||
"get_energy_usage": SmartErrorCode.JSON_DECODE_FAIL_ERROR,
|
||||
},
|
||||
2.002,
|
||||
True,
|
||||
id="falls_back_to_get_current_power",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"get_energy_usage": _energy_usage(),
|
||||
"get_current_power": SmartErrorCode.PARAMS_ERROR,
|
||||
},
|
||||
None,
|
||||
False,
|
||||
id="returns_none_without_current_power",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_v2_current_power_fallbacks(
|
||||
dev: SmartDevice,
|
||||
response_updates: dict[str, object],
|
||||
expected_power: float | None,
|
||||
expect_energy_usage_removed: bool,
|
||||
) -> None:
|
||||
"""Test fallback behavior when higher precision power sources are unavailable."""
|
||||
energy_module = _get_v2_energy_module(dev)
|
||||
resp = _copy_last_update(dev, "get_emeter_data")
|
||||
resp.update(response_updates)
|
||||
|
||||
with patch.object(dev.protocol, "query", return_value=resp):
|
||||
await dev.update()
|
||||
|
||||
if expect_energy_usage_removed:
|
||||
assert "get_energy_usage" not in energy_module.data
|
||||
|
||||
assert energy_module.current_consumption == expected_current_consumption
|
||||
assert energy_module.current_consumption == expected_power
|
||||
assert energy_module.status.power == expected_power
|
||||
|
||||
# message should only be logged once
|
||||
assert msg not in caplog.text
|
||||
|
||||
@has_emeter_smart
|
||||
@pytest.mark.parametrize(
|
||||
("responses", "expected_calls", "expected_power"),
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"get_emeter_data": {
|
||||
"current_ma": 25,
|
||||
"energy_wh": 321,
|
||||
"power_mw": 3003,
|
||||
"voltage_mv": 120456,
|
||||
}
|
||||
},
|
||||
["get_emeter_data"],
|
||||
3.003,
|
||||
id="prefers_get_emeter_data",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"get_emeter_data": _device_error(
|
||||
"get_emeter_data", SmartErrorCode.PARAMS_ERROR
|
||||
),
|
||||
"get_energy_usage": _energy_usage(current_power=2002),
|
||||
},
|
||||
["get_emeter_data", "get_energy_usage"],
|
||||
2.002,
|
||||
id="falls_back_to_get_energy_usage",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"get_emeter_data": _device_error(
|
||||
"get_emeter_data", SmartErrorCode.UNKNOWN_METHOD_ERROR
|
||||
),
|
||||
"get_energy_usage": _energy_usage(current_power=2002),
|
||||
},
|
||||
["get_emeter_data", "get_energy_usage"],
|
||||
2.002,
|
||||
id="falls_back_after_unknown_method",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"get_emeter_data": _device_error(
|
||||
"get_emeter_data", SmartErrorCode.PARAMS_ERROR
|
||||
),
|
||||
"get_energy_usage": _energy_usage(),
|
||||
"get_current_power": {"current_power": 2.002},
|
||||
},
|
||||
["get_emeter_data", "get_energy_usage", "get_current_power"],
|
||||
2.002,
|
||||
id="falls_back_to_get_current_power",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"get_emeter_data": _device_error(
|
||||
"get_emeter_data", SmartErrorCode.PARAMS_ERROR
|
||||
),
|
||||
"get_energy_usage": _device_error(
|
||||
"get_energy_usage", SmartErrorCode.JSON_DECODE_FAIL_ERROR
|
||||
),
|
||||
"get_current_power": {"current_power": 2.002},
|
||||
},
|
||||
["get_emeter_data", "get_energy_usage", "get_current_power"],
|
||||
2.002,
|
||||
id="continues_after_get_energy_usage_error",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"get_emeter_data": _device_error(
|
||||
"get_emeter_data", SmartErrorCode.PARAMS_ERROR
|
||||
),
|
||||
"get_energy_usage": _energy_usage(),
|
||||
"get_current_power": _device_error(
|
||||
"get_current_power", SmartErrorCode.PARAMS_ERROR
|
||||
),
|
||||
},
|
||||
["get_emeter_data", "get_energy_usage", "get_current_power"],
|
||||
None,
|
||||
id="returns_none_when_all_sources_fail",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_v2_get_status_current_power_sources(
|
||||
dev: SmartDevice,
|
||||
responses: dict[str, object],
|
||||
expected_calls: list[str],
|
||||
expected_power: float | None,
|
||||
) -> None:
|
||||
"""get_status should use the same source precedence as cached status."""
|
||||
energy_module = _get_v2_energy_module(dev)
|
||||
calls: list[str] = []
|
||||
|
||||
with patch.object(dev.protocol, "query", side_effect=_mock_query(responses, calls)):
|
||||
status = await energy_module.get_status()
|
||||
|
||||
assert calls == expected_calls
|
||||
assert status.power == expected_power
|
||||
|
||||
|
||||
@has_emeter_smart
|
||||
@pytest.mark.parametrize(
|
||||
("responses", "expected_calls", "expected_match"),
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"get_emeter_data": _device_error(
|
||||
"get_emeter_data", SmartErrorCode.JSON_DECODE_FAIL_ERROR
|
||||
),
|
||||
},
|
||||
["get_emeter_data"],
|
||||
"get_emeter_data",
|
||||
id="get_emeter_data",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"get_emeter_data": _device_error(
|
||||
"get_emeter_data", SmartErrorCode.PARAMS_ERROR
|
||||
),
|
||||
"get_energy_usage": _energy_usage(),
|
||||
"get_current_power": _device_error(
|
||||
"get_current_power", SmartErrorCode.JSON_DECODE_FAIL_ERROR
|
||||
),
|
||||
},
|
||||
["get_emeter_data", "get_energy_usage", "get_current_power"],
|
||||
"get_current_power",
|
||||
id="get_current_power",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_v2_get_status_raises_unexpected_optional_method_errors(
|
||||
dev: SmartDevice,
|
||||
responses: dict[str, object],
|
||||
expected_calls: list[str],
|
||||
expected_match: str,
|
||||
) -> None:
|
||||
"""Only unsupported optional method errors should fall back silently."""
|
||||
energy_module = _get_v2_energy_module(dev)
|
||||
calls: list[str] = []
|
||||
|
||||
with (
|
||||
patch.object(dev.protocol, "query", side_effect=_mock_query(responses, calls)),
|
||||
pytest.raises(DeviceError, match=expected_match),
|
||||
):
|
||||
await energy_module.get_status()
|
||||
|
||||
assert calls == expected_calls
|
||||
|
||||
|
||||
@p110_v1_smart
|
||||
async def test_v1_get_status_uses_energy_usage_only(dev: SmartDevice) -> None:
|
||||
"""V1 devices should return energy usage data without querying current power."""
|
||||
energy_module = _get_energy_module(dev)
|
||||
calls: list[str] = []
|
||||
|
||||
with patch.object(
|
||||
dev.protocol,
|
||||
"query",
|
||||
side_effect=_mock_query({"get_energy_usage": _energy_usage()}, calls),
|
||||
):
|
||||
status = await energy_module.get_status()
|
||||
|
||||
assert calls == ["get_energy_usage"]
|
||||
assert status.power is None
|
||||
|
||||
|
||||
@p110_v1_smart
|
||||
async def test_v1_get_status_raises_on_get_energy_usage_error(
|
||||
dev: SmartDevice,
|
||||
) -> None:
|
||||
"""V1 devices should still raise when get_energy_usage fails."""
|
||||
energy_module = _get_energy_module(dev)
|
||||
|
||||
async def query(request: dict[str, dict | None]) -> dict[str, dict]:
|
||||
method = next(iter(request))
|
||||
raise DeviceError(method, error_code=SmartErrorCode.JSON_DECODE_FAIL_ERROR)
|
||||
|
||||
with (
|
||||
patch.object(dev.protocol, "query", side_effect=query),
|
||||
pytest.raises(DeviceError, match="get_energy_usage"),
|
||||
):
|
||||
await energy_module.get_status()
|
||||
|
||||
|
||||
@s515d_smart
|
||||
async def test_s515d_missing_get_current_power_is_optional(dev: SmartDevice) -> None:
|
||||
"""S515D exposes current power without a working get_current_power query."""
|
||||
energy_module = _get_v2_energy_module(dev)
|
||||
|
||||
assert energy_module.disabled is False
|
||||
assert energy_module._last_update_error is None
|
||||
assert "get_current_power" not in energy_module.data
|
||||
assert energy_module.current_consumption == 0.0
|
||||
assert energy_module.status.power == 0.0
|
||||
|
||||
Reference in New Issue
Block a user