Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Implement choice feature type #880

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions kasa/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ class Category(Enum):
#: If set, this property will be used to set *minimum_value* and *maximum_value*.
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
id: str | None = None

Expand All @@ -108,6 +115,10 @@ def __post_init__(self):
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
if self.category is Feature.Category.Unset:
if self.attribute_setter:
Expand Down Expand Up @@ -147,6 +158,12 @@ async def set_value(self, value):
f"Value {value} out of range "
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
if self.type == Feature.Type.Action:
Expand Down
6 changes: 6 additions & 0 deletions kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ def query(self):
def data(self):
"""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):
"""Add module feature."""

Expand Down
46 changes: 37 additions & 9 deletions kasa/smart/modules/alarmmodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@

from __future__ import annotations

from typing import TYPE_CHECKING

from ...feature import Feature
from ..smartmodule import SmartModule

if TYPE_CHECKING:
from ..smartdevice import SmartDevice


class AlarmModule(SmartModule):
"""Implementation of alarm module."""
Expand All @@ -23,8 +18,12 @@ def query(self) -> dict:
"get_support_alarm_type_list": None, # This should be needed only once
}

def __init__(self, device: SmartDevice, module: str):
super().__init__(device, module)
def _initialize_features(self):
"""Initialize features.

This is implemented as some features depend on device responses.
"""
device = self._device
self._add_feature(
Feature(
device,
Expand All @@ -46,12 +45,26 @@ def __init__(self, device: SmartDevice, module: str):
)
self._add_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(
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(
Expand All @@ -78,6 +91,15 @@ def alarm_sound(self):
"""Return current alarm sound."""
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
def alarm_sounds(self) -> list[str]:
"""Return list of available alarm sounds."""
Expand All @@ -88,6 +110,12 @@ def alarm_volume(self):
"""Return alarm 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
def active(self) -> bool:
"""Return true if alarm is active."""
Expand Down
1 change: 1 addition & 0 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ async def _initialize_features(self):
)

for module in self._modules.values():
module._initialize_features()
for feat in module._module_features.values():
self._add_feature(feat)

Expand Down
25 changes: 19 additions & 6 deletions kasa/tests/discovery_fixtures.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import copy
from dataclasses import dataclass
from json import dumps as json_dumps

Expand All @@ -8,7 +9,7 @@
from kasa.xortransport import XorEncryption

from .fakeprotocol_iot import FakeIotProtocol
from .fakeprotocol_smart import FakeSmartProtocol
from .fakeprotocol_smart import FakeSmartProtocol, FakeSmartTransport
from .fixtureinfo import FixtureInfo, filter_fixtures, idgenerator


Expand Down Expand Up @@ -65,6 +66,7 @@ def parametrize_discovery(desc, *, data_root_filter, protocol_filter=None):
ids=idgenerator,
)
def discovery_mock(request, mocker):
"""Mock discovery and patch protocol queries to use Fake protocols."""
fixture_info: FixtureInfo = request.param
fixture_data = fixture_info.data

Expand Down Expand Up @@ -157,12 +159,23 @@ async def _query(request, retry_count: int = 3):
def discovery_data(request, mocker):
"""Return raw discovery file contents as JSON. Used for discovery tests."""
fixture_info = request.param
mocker.patch("kasa.IotProtocol.query", return_value=fixture_info.data)
mocker.patch("kasa.SmartProtocol.query", return_value=fixture_info.data)
if "discovery_result" in fixture_info.data:
return {"result": fixture_info.data["discovery_result"]}
fixture_data = copy.deepcopy(fixture_info.data)
# Add missing queries to fixture data
if "component_nego" in fixture_data:
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:
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())
Expand Down
3 changes: 0 additions & 3 deletions kasa/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,9 +354,6 @@ async def _state(dev: Device):

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"])
res = await runner.invoke(
cli,
Expand Down
18 changes: 18 additions & 0 deletions kasa/tests/test_feature.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from pytest_mock import MockFixture

from kasa import Feature

Expand Down Expand Up @@ -110,6 +111,23 @@ async def test_feature_action(mocker):
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])
async def test_precision_hint(dummy_feature, precision_hint):
"""Test that precision hint works as expected."""
Expand Down