mirror of
https://github.com/python-kasa/python-kasa.git
synced 2026-07-11 09:52:04 +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:
@@ -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