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

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:
ZeliardM
2026-07-10 21:21:18 -04:00
committed by GitHub
parent b2206841dd
commit 202b80e9b3
6 changed files with 1118 additions and 57 deletions

View File

@@ -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}

View 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
}
}
}

View File

@@ -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