Files
python-kasa/tests/smart/modules/test_energy.py
ZeliardM 202b80e9b3
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 V2 Energy Monitoring for S515D (#1663)
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.
2026-07-10 21:21:18 -04:00

427 lines
14 KiB
Python

import copy
import logging
from contextlib import nullcontext as does_not_raise
from unittest.mock import patch
import pytest
from kasa import DeviceError, Module
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 ...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"},
)
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:
assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is False
else:
assert energy_module.supports(Energy.ModuleFeature.VOLTAGE_CURRENT) is True
@has_emeter_smart
async def test_get_energy_usage_error(
dev: SmartDevice, caplog: pytest.LogCaptureFixture
) -> None:
"""Test errors on get_energy_usage."""
caplog.set_level(logging.DEBUG)
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:
expected = "get_energy_usage"
expected_current_consumption = 2.002
else:
expected = "current_power"
expected_current_consumption = None
assert expected in energy_module.data
assert energy_module.current_consumption is not None
assert energy_module.consumption_today is not None
assert energy_module.consumption_this_month is not None
resp = _copy_last_update(dev)
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
# version 1 and get_energy_usage is in error
with patch.object(dev.protocol, "query", return_value=resp):
await dev.update()
with expected_raise:
assert "get_energy_usage" not in energy_module.data
assert energy_module.current_consumption == expected_current_consumption
assert energy_module.consumption_today is None
assert energy_module.consumption_this_month is None
msg = (
f"Removed key get_energy_usage from response for device {dev.host}"
" as it returned error: JSON_DECODE_FAIL_ERROR"
)
if version > 1:
assert msg in caplog.text
@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()
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_power
assert energy_module.status.power == expected_power
@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