mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-23 03:33:35 +00:00
Implement choice feature type (#880)
Implement the choice feature type allowing to provide a list of choices that can be set. Co-authored-by: sdb9696
This commit is contained in:
parent
d3544b4989
commit
300d823895
@ -92,6 +92,13 @@ class Feature:
|
|||||||
#: If set, this property will be used to set *minimum_value* and *maximum_value*.
|
#: If set, this property will be used to set *minimum_value* and *maximum_value*.
|
||||||
range_getter: str | None = None
|
range_getter: str | None = None
|
||||||
|
|
||||||
|
# Choice-specific attributes
|
||||||
|
#: List of choices as enum
|
||||||
|
choices: list[str] | None = None
|
||||||
|
#: Attribute name of the choices getter property.
|
||||||
|
#: If set, this property will be used to set *choices*.
|
||||||
|
choices_getter: str | None = None
|
||||||
|
|
||||||
#: Identifier
|
#: Identifier
|
||||||
id: str | None = None
|
id: str | None = None
|
||||||
|
|
||||||
@ -108,6 +115,10 @@ class Feature:
|
|||||||
container, self.range_getter
|
container, self.range_getter
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Populate choices, if choices_getter is given
|
||||||
|
if self.choices_getter is not None:
|
||||||
|
self.choices = getattr(container, self.choices_getter)
|
||||||
|
|
||||||
# Set the category, if unset
|
# Set the category, if unset
|
||||||
if self.category is Feature.Category.Unset:
|
if self.category is Feature.Category.Unset:
|
||||||
if self.attribute_setter:
|
if self.attribute_setter:
|
||||||
@ -147,6 +158,12 @@ class Feature:
|
|||||||
f"Value {value} out of range "
|
f"Value {value} out of range "
|
||||||
f"[{self.minimum_value}, {self.maximum_value}]"
|
f"[{self.minimum_value}, {self.maximum_value}]"
|
||||||
)
|
)
|
||||||
|
elif self.type == Feature.Type.Choice: # noqa: SIM102
|
||||||
|
if value not in self.choices:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unexpected value for {self.name}: {value}"
|
||||||
|
f" - allowed: {self.choices}"
|
||||||
|
)
|
||||||
|
|
||||||
container = self.container if self.container is not None else self.device
|
container = self.container if self.container is not None else self.device
|
||||||
if self.type == Feature.Type.Action:
|
if self.type == Feature.Type.Action:
|
||||||
|
@ -40,6 +40,12 @@ class Module(ABC):
|
|||||||
def data(self):
|
def data(self):
|
||||||
"""Return the module specific raw data from the last update."""
|
"""Return the module specific raw data from the last update."""
|
||||||
|
|
||||||
|
def _initialize_features(self): # noqa: B027
|
||||||
|
"""Initialize features after the initial update.
|
||||||
|
|
||||||
|
This can be implemented if features depend on module query responses.
|
||||||
|
"""
|
||||||
|
|
||||||
def _add_feature(self, feature: Feature):
|
def _add_feature(self, feature: Feature):
|
||||||
"""Add module feature."""
|
"""Add module feature."""
|
||||||
|
|
||||||
|
@ -2,14 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from ...feature import Feature
|
from ...feature import Feature
|
||||||
from ..smartmodule import SmartModule
|
from ..smartmodule import SmartModule
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..smartdevice import SmartDevice
|
|
||||||
|
|
||||||
|
|
||||||
class AlarmModule(SmartModule):
|
class AlarmModule(SmartModule):
|
||||||
"""Implementation of alarm module."""
|
"""Implementation of alarm module."""
|
||||||
@ -23,8 +18,12 @@ class AlarmModule(SmartModule):
|
|||||||
"get_support_alarm_type_list": None, # This should be needed only once
|
"get_support_alarm_type_list": None, # This should be needed only once
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, device: SmartDevice, module: str):
|
def _initialize_features(self):
|
||||||
super().__init__(device, module)
|
"""Initialize features.
|
||||||
|
|
||||||
|
This is implemented as some features depend on device responses.
|
||||||
|
"""
|
||||||
|
device = self._device
|
||||||
self._add_feature(
|
self._add_feature(
|
||||||
Feature(
|
Feature(
|
||||||
device,
|
device,
|
||||||
@ -46,12 +45,26 @@ class AlarmModule(SmartModule):
|
|||||||
)
|
)
|
||||||
self._add_feature(
|
self._add_feature(
|
||||||
Feature(
|
Feature(
|
||||||
device, "Alarm sound", container=self, attribute_getter="alarm_sound"
|
device,
|
||||||
|
"Alarm sound",
|
||||||
|
container=self,
|
||||||
|
attribute_getter="alarm_sound",
|
||||||
|
attribute_setter="set_alarm_sound",
|
||||||
|
category=Feature.Category.Config,
|
||||||
|
type=Feature.Type.Choice,
|
||||||
|
choices_getter="alarm_sounds",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._add_feature(
|
self._add_feature(
|
||||||
Feature(
|
Feature(
|
||||||
device, "Alarm volume", container=self, attribute_getter="alarm_volume"
|
device,
|
||||||
|
"Alarm volume",
|
||||||
|
container=self,
|
||||||
|
attribute_getter="alarm_volume",
|
||||||
|
attribute_setter="set_alarm_volume",
|
||||||
|
category=Feature.Category.Config,
|
||||||
|
type=Feature.Type.Choice,
|
||||||
|
choices=["low", "high"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._add_feature(
|
self._add_feature(
|
||||||
@ -78,6 +91,15 @@ class AlarmModule(SmartModule):
|
|||||||
"""Return current alarm sound."""
|
"""Return current alarm sound."""
|
||||||
return self.data["get_alarm_configure"]["type"]
|
return self.data["get_alarm_configure"]["type"]
|
||||||
|
|
||||||
|
async def set_alarm_sound(self, sound: str):
|
||||||
|
"""Set alarm sound.
|
||||||
|
|
||||||
|
See *alarm_sounds* for list of available sounds.
|
||||||
|
"""
|
||||||
|
payload = self.data["get_alarm_configure"].copy()
|
||||||
|
payload["type"] = sound
|
||||||
|
return await self.call("set_alarm_configure", payload)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def alarm_sounds(self) -> list[str]:
|
def alarm_sounds(self) -> list[str]:
|
||||||
"""Return list of available alarm sounds."""
|
"""Return list of available alarm sounds."""
|
||||||
@ -88,6 +110,12 @@ class AlarmModule(SmartModule):
|
|||||||
"""Return alarm volume."""
|
"""Return alarm volume."""
|
||||||
return self.data["get_alarm_configure"]["volume"]
|
return self.data["get_alarm_configure"]["volume"]
|
||||||
|
|
||||||
|
async def set_alarm_volume(self, volume: str):
|
||||||
|
"""Set alarm volume."""
|
||||||
|
payload = self.data["get_alarm_configure"].copy()
|
||||||
|
payload["volume"] = volume
|
||||||
|
return await self.call("set_alarm_configure", payload)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def active(self) -> bool:
|
def active(self) -> bool:
|
||||||
"""Return true if alarm is active."""
|
"""Return true if alarm is active."""
|
||||||
|
@ -305,6 +305,7 @@ class SmartDevice(Device, Bulb):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for module in self._modules.values():
|
for module in self._modules.values():
|
||||||
|
module._initialize_features()
|
||||||
for feat in module._module_features.values():
|
for feat in module._module_features.values():
|
||||||
self._add_feature(feat)
|
self._add_feature(feat)
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from json import dumps as json_dumps
|
from json import dumps as json_dumps
|
||||||
|
|
||||||
@ -8,7 +9,7 @@ import pytest
|
|||||||
from kasa.xortransport import XorEncryption
|
from kasa.xortransport import XorEncryption
|
||||||
|
|
||||||
from .fakeprotocol_iot import FakeIotProtocol
|
from .fakeprotocol_iot import FakeIotProtocol
|
||||||
from .fakeprotocol_smart import FakeSmartProtocol
|
from .fakeprotocol_smart import FakeSmartProtocol, FakeSmartTransport
|
||||||
from .fixtureinfo import FixtureInfo, filter_fixtures, idgenerator
|
from .fixtureinfo import FixtureInfo, filter_fixtures, idgenerator
|
||||||
|
|
||||||
|
|
||||||
@ -65,6 +66,7 @@ new_discovery = parametrize_discovery(
|
|||||||
ids=idgenerator,
|
ids=idgenerator,
|
||||||
)
|
)
|
||||||
def discovery_mock(request, mocker):
|
def discovery_mock(request, mocker):
|
||||||
|
"""Mock discovery and patch protocol queries to use Fake protocols."""
|
||||||
fixture_info: FixtureInfo = request.param
|
fixture_info: FixtureInfo = request.param
|
||||||
fixture_data = fixture_info.data
|
fixture_data = fixture_info.data
|
||||||
|
|
||||||
@ -157,12 +159,23 @@ def discovery_mock(request, mocker):
|
|||||||
def discovery_data(request, mocker):
|
def discovery_data(request, mocker):
|
||||||
"""Return raw discovery file contents as JSON. Used for discovery tests."""
|
"""Return raw discovery file contents as JSON. Used for discovery tests."""
|
||||||
fixture_info = request.param
|
fixture_info = request.param
|
||||||
mocker.patch("kasa.IotProtocol.query", return_value=fixture_info.data)
|
fixture_data = copy.deepcopy(fixture_info.data)
|
||||||
mocker.patch("kasa.SmartProtocol.query", return_value=fixture_info.data)
|
# Add missing queries to fixture data
|
||||||
if "discovery_result" in fixture_info.data:
|
if "component_nego" in fixture_data:
|
||||||
return {"result": fixture_info.data["discovery_result"]}
|
components = {
|
||||||
|
comp["id"]: int(comp["ver_code"])
|
||||||
|
for comp in fixture_data["component_nego"]["component_list"]
|
||||||
|
}
|
||||||
|
for k, v in FakeSmartTransport.FIXTURE_MISSING_MAP.items():
|
||||||
|
# Value is a tuple of component,reponse
|
||||||
|
if k not in fixture_data and v[0] in components:
|
||||||
|
fixture_data[k] = v[1]
|
||||||
|
mocker.patch("kasa.IotProtocol.query", return_value=fixture_data)
|
||||||
|
mocker.patch("kasa.SmartProtocol.query", return_value=fixture_data)
|
||||||
|
if "discovery_result" in fixture_data:
|
||||||
|
return {"result": fixture_data["discovery_result"]}
|
||||||
else:
|
else:
|
||||||
return {"system": {"get_sysinfo": fixture_info.data["system"]["get_sysinfo"]}}
|
return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(params=UNSUPPORTED_DEVICES.values(), ids=UNSUPPORTED_DEVICES.keys())
|
@pytest.fixture(params=UNSUPPORTED_DEVICES.values(), ids=UNSUPPORTED_DEVICES.keys())
|
||||||
|
@ -354,9 +354,6 @@ async def test_credentials(discovery_mock, mocker, runner):
|
|||||||
|
|
||||||
mocker.patch("kasa.cli.state", new=_state)
|
mocker.patch("kasa.cli.state", new=_state)
|
||||||
|
|
||||||
mocker.patch("kasa.IotProtocol.query", return_value=discovery_mock.query_data)
|
|
||||||
mocker.patch("kasa.SmartProtocol.query", return_value=discovery_mock.query_data)
|
|
||||||
|
|
||||||
dr = DiscoveryResult(**discovery_mock.discovery_data["result"])
|
dr = DiscoveryResult(**discovery_mock.discovery_data["result"])
|
||||||
res = await runner.invoke(
|
res = await runner.invoke(
|
||||||
cli,
|
cli,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from pytest_mock import MockFixture
|
||||||
|
|
||||||
from kasa import Feature
|
from kasa import Feature
|
||||||
|
|
||||||
@ -110,6 +111,23 @@ async def test_feature_action(mocker):
|
|||||||
mock_call_action.assert_called()
|
mock_call_action.assert_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_feature_choice_list(dummy_feature, caplog, mocker: MockFixture):
|
||||||
|
"""Test the choice feature type."""
|
||||||
|
dummy_feature.type = Feature.Type.Choice
|
||||||
|
dummy_feature.choices = ["first", "second"]
|
||||||
|
|
||||||
|
mock_setter = mocker.patch.object(dummy_feature.device, "dummysetter", create=True)
|
||||||
|
await dummy_feature.set_value("first")
|
||||||
|
mock_setter.assert_called_with("first")
|
||||||
|
mock_setter.reset_mock()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await dummy_feature.set_value("invalid")
|
||||||
|
assert "Unexpected value" in caplog.text
|
||||||
|
|
||||||
|
mock_setter.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("precision_hint", [1, 2, 3])
|
@pytest.mark.parametrize("precision_hint", [1, 2, 3])
|
||||||
async def test_precision_hint(dummy_feature, precision_hint):
|
async def test_precision_hint(dummy_feature, precision_hint):
|
||||||
"""Test that precision hint works as expected."""
|
"""Test that precision hint works as expected."""
|
||||||
|
Loading…
Reference in New Issue
Block a user