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

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