mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-23 03:33:35 +00:00
Remove Custom Parsers
- Fix: Back out custom value parser logic. - Fix: Remove overloaded range set functionality. - Fix: Improve error messages when user forgets quotes around cli arguments.
This commit is contained in:
parent
f296d941a1
commit
d2ce2d20d4
@ -6,10 +6,7 @@ import ast
|
|||||||
|
|
||||||
import asyncclick as click
|
import asyncclick as click
|
||||||
|
|
||||||
from kasa import (
|
from kasa import Device, Feature
|
||||||
Device,
|
|
||||||
Feature,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
echo,
|
echo,
|
||||||
@ -133,7 +130,22 @@ async def feature(
|
|||||||
echo(f"{feat.name} ({name}): {feat.value}{unit}")
|
echo(f"{feat.name} ({name}): {feat.value}{unit}")
|
||||||
return feat.value
|
return feat.value
|
||||||
|
|
||||||
value = feat.parse_value(value, ast.literal_eval)
|
try:
|
||||||
|
# Attempt to parse as python literal.
|
||||||
|
value = ast.literal_eval(value)
|
||||||
|
except ValueError:
|
||||||
|
# The value is probably an unquoted string, so we'll raise an error,
|
||||||
|
# and tell the user to quote the string.
|
||||||
|
raise click.exceptions.BadParameter(
|
||||||
|
f'{repr(value)} for {name} (Perhaps you forgot to "quote" the value?)'
|
||||||
|
) from SyntaxError
|
||||||
|
except SyntaxError:
|
||||||
|
# There are likely miss-matched quotes or odd characters in the input,
|
||||||
|
# so abort and complain to the user.
|
||||||
|
raise click.exceptions.BadParameter(
|
||||||
|
f"{repr(value)} for {name}"
|
||||||
|
) from SyntaxError
|
||||||
|
|
||||||
echo(f"Changing {name} from {feat.value} to {value}")
|
echo(f"Changing {name} from {feat.value} to {value}")
|
||||||
response = await dev.features[name].set_value(value)
|
response = await dev.features[name].set_value(value)
|
||||||
await dev.update()
|
await dev.update()
|
||||||
|
@ -163,9 +163,6 @@ class Feature:
|
|||||||
#: If set, this property will be used to get *choices*.
|
#: If set, this property will be used to get *choices*.
|
||||||
choices_getter: str | Callable[[], list[str]] | None = None
|
choices_getter: str | Callable[[], list[str]] | None = None
|
||||||
|
|
||||||
#: Value converter, for when working with complex types.
|
|
||||||
value_parser: str | None = None
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""Handle late-binding of members."""
|
"""Handle late-binding of members."""
|
||||||
# Populate minimum & maximum values, if range_getter is given
|
# Populate minimum & maximum values, if range_getter is given
|
||||||
@ -258,7 +255,7 @@ class Feature:
|
|||||||
elif self.type == Feature.Type.Choice: # noqa: SIM102
|
elif self.type == Feature.Type.Choice: # noqa: SIM102
|
||||||
if not self.choices or value not in self.choices:
|
if not self.choices or value not in self.choices:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unexpected value for {self.name}: {value}"
|
f"Unexpected value for {self.name}: '{value}'"
|
||||||
f" - allowed: {self.choices}"
|
f" - allowed: {self.choices}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -273,26 +270,6 @@ class Feature:
|
|||||||
|
|
||||||
return await attribute_setter(value)
|
return await attribute_setter(value)
|
||||||
|
|
||||||
def parse_value(
|
|
||||||
self, value: str, fallback: Callable[[str], Any | None] = lambda x: None
|
|
||||||
) -> Any | None:
|
|
||||||
"""Attempt to parse a given string into a value accepted by this feature."""
|
|
||||||
parser = self._get_property_value(self.value_parser)
|
|
||||||
parser = parser if parser else fallback
|
|
||||||
allowed = f"{self.choices}" if self.choices else "Unknown"
|
|
||||||
try:
|
|
||||||
parsed = parser(value)
|
|
||||||
if parsed is None:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unexpected value for {self.name}: {value}"
|
|
||||||
f" - allowed: {allowed}"
|
|
||||||
)
|
|
||||||
return parsed
|
|
||||||
except SyntaxError as se:
|
|
||||||
raise ValueError(
|
|
||||||
f"{se.msg} for {self.name}: {value}" f" - allowed: {allowed}",
|
|
||||||
) from se
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
try:
|
try:
|
||||||
value = self.value
|
value = self.value
|
||||||
|
@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Literal, overload
|
|
||||||
|
|
||||||
from ...exceptions import KasaException
|
from ...exceptions import KasaException
|
||||||
from ...feature import Feature
|
from ...feature import Feature
|
||||||
@ -69,7 +68,6 @@ class Motion(IotModule):
|
|||||||
attribute_setter="_set_range_cli",
|
attribute_setter="_set_range_cli",
|
||||||
type=Feature.Type.Choice,
|
type=Feature.Type.Choice,
|
||||||
choices_getter="ranges",
|
choices_getter="ranges",
|
||||||
value_parser="parse_range_value",
|
|
||||||
category=Feature.Category.Config,
|
category=Feature.Category.Config,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -108,7 +106,7 @@ class Motion(IotModule):
|
|||||||
device=self._device,
|
device=self._device,
|
||||||
container=self,
|
container=self,
|
||||||
id="pir_value",
|
id="pir_value",
|
||||||
name="PIR Reading",
|
name="PIR Value",
|
||||||
icon="mdi:motion-sensor",
|
icon="mdi:motion-sensor",
|
||||||
attribute_getter="pir_value",
|
attribute_getter="pir_value",
|
||||||
attribute_setter=None,
|
attribute_setter=None,
|
||||||
@ -117,21 +115,6 @@ class Motion(IotModule):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
self._add_feature(
|
|
||||||
Feature(
|
|
||||||
device=self._device,
|
|
||||||
container=self,
|
|
||||||
id="pir_percent",
|
|
||||||
name="PIR Percentage",
|
|
||||||
icon="mdi:motion-sensor",
|
|
||||||
attribute_getter="pir_percent",
|
|
||||||
attribute_setter=None,
|
|
||||||
type=Feature.Type.Sensor,
|
|
||||||
category=Feature.Category.Info,
|
|
||||||
unit_getter=lambda: "%",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self._add_feature(
|
self._add_feature(
|
||||||
Feature(
|
Feature(
|
||||||
device=self._device,
|
device=self._device,
|
||||||
@ -188,6 +171,21 @@ class Motion(IotModule):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._add_feature(
|
||||||
|
Feature(
|
||||||
|
device=self._device,
|
||||||
|
container=self,
|
||||||
|
id="pir_percent",
|
||||||
|
name="PIR Percentile",
|
||||||
|
icon="mdi:motion-sensor",
|
||||||
|
attribute_getter="pir_percent",
|
||||||
|
attribute_setter=None,
|
||||||
|
type=Feature.Type.Sensor,
|
||||||
|
category=Feature.Category.Debug,
|
||||||
|
unit_getter=lambda: "%",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def query(self) -> dict:
|
def query(self) -> dict:
|
||||||
"""Request PIR configuration."""
|
"""Request PIR configuration."""
|
||||||
req = merge(
|
req = merge(
|
||||||
@ -233,33 +231,15 @@ class Motion(IotModule):
|
|||||||
"""Enable/disable PIR."""
|
"""Enable/disable PIR."""
|
||||||
return await self.call("set_enable", {"enable": int(state)})
|
return await self.call("set_enable", {"enable": int(state)})
|
||||||
|
|
||||||
def _parse_range_value(self, value: str) -> int | Range | None:
|
|
||||||
"""Attempt to parse a range value from the given string."""
|
|
||||||
_LOGGER.debug("Parse Range Value: %s", value)
|
|
||||||
parsed: int | Range | None = None
|
|
||||||
try:
|
|
||||||
parsed = int(value)
|
|
||||||
_LOGGER.debug("Parse Range Value: %s is an integer.", value)
|
|
||||||
return parsed
|
|
||||||
except ValueError:
|
|
||||||
_LOGGER.debug("Parse Range Value: %s is not an integer.", value)
|
|
||||||
value = value.strip().upper()
|
|
||||||
if value in Range._member_names_:
|
|
||||||
_LOGGER.debug("Parse Range Value: %s is an enumeration.", value)
|
|
||||||
parsed = Range[value]
|
|
||||||
return parsed
|
|
||||||
_LOGGER.debug("Parse Range Value: %s is not a Range Value.", value)
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ranges(self) -> list[Range]:
|
def ranges(self) -> list[str]:
|
||||||
"""Return set of supported range classes."""
|
"""Return set of supported range classes."""
|
||||||
range_min = 0
|
range_min = 0
|
||||||
range_max = len(self.config["array"])
|
range_max = len(self.config["array"])
|
||||||
valid_ranges = list()
|
valid_ranges = list()
|
||||||
for r in Range:
|
for r in Range:
|
||||||
if (r.value >= range_min) and (r.value < range_max):
|
if (r.value >= range_min) and (r.value < range_max):
|
||||||
valid_ranges.append(r)
|
valid_ranges.append(r.name)
|
||||||
return valid_ranges
|
return valid_ranges
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -267,45 +247,27 @@ class Motion(IotModule):
|
|||||||
"""Return motion detection Range."""
|
"""Return motion detection Range."""
|
||||||
return Range(self.config["trigger_index"])
|
return Range(self.config["trigger_index"])
|
||||||
|
|
||||||
@overload
|
async def set_range(self, range: Range) -> dict:
|
||||||
async def set_range(self, *, range: Range) -> dict: ...
|
|
||||||
|
|
||||||
@overload
|
|
||||||
async def set_range(self, *, range: Literal[Range.Custom], value: int) -> dict: ...
|
|
||||||
|
|
||||||
@overload
|
|
||||||
async def set_range(self, *, value: int) -> dict: ...
|
|
||||||
|
|
||||||
async def set_range(
|
|
||||||
self, *, range: Range | None = None, value: int | None = None
|
|
||||||
) -> dict:
|
|
||||||
"""Set the Range for the sensor.
|
"""Set the Range for the sensor.
|
||||||
|
|
||||||
:param Range: for using standard Ranges
|
:param Range: the range class to use.
|
||||||
:param custom_Range: Range in decimeters, overrides the Range parameter
|
|
||||||
"""
|
"""
|
||||||
if value is not None:
|
payload = {"index": range.value}
|
||||||
if range is not None and range is not Range.Custom:
|
|
||||||
raise KasaException(
|
|
||||||
f"Refusing to set non-custom range {range} to value {value}."
|
|
||||||
)
|
|
||||||
elif value is None:
|
|
||||||
raise KasaException("Custom range threshold may not be set to None.")
|
|
||||||
payload = {"index": Range.Custom.value, "value": value}
|
|
||||||
elif range is not None:
|
|
||||||
payload = {"index": range.value}
|
|
||||||
else:
|
|
||||||
raise KasaException("Either range or value needs to be defined")
|
|
||||||
|
|
||||||
return await self.call("set_trigger_sens", payload)
|
return await self.call("set_trigger_sens", payload)
|
||||||
|
|
||||||
async def _set_range_cli(self, input: Range | int) -> dict:
|
def _parse_range_value(self, value: str) -> Range:
|
||||||
if isinstance(input, Range):
|
"""Attempt to parse a range value from the given string."""
|
||||||
return await self.set_range(range=input)
|
value = value.strip().capitalize()
|
||||||
elif isinstance(input, int):
|
if value not in Range._member_names_:
|
||||||
return await self.set_range(value=input)
|
raise KasaException(
|
||||||
else:
|
f"Invalid range value: '{value}'."
|
||||||
raise KasaException(f"Invalid type: {type(input)} given to cli motion set.")
|
f" Valid options are: {Range._member_names_}"
|
||||||
|
)
|
||||||
|
return Range[value]
|
||||||
|
|
||||||
|
async def _set_range_cli(self, input: str) -> dict:
|
||||||
|
value = self._parse_range_value(input)
|
||||||
|
return await self.set_range(range=value)
|
||||||
|
|
||||||
def get_range_threshold(self, range_type: Range) -> int:
|
def get_range_threshold(self, range_type: Range) -> int:
|
||||||
"""Get the distance threshold at which the PIR sensor is will trigger."""
|
"""Get the distance threshold at which the PIR sensor is will trigger."""
|
||||||
@ -322,7 +284,8 @@ class Motion(IotModule):
|
|||||||
|
|
||||||
async def set_threshold(self, value: int) -> dict:
|
async def set_threshold(self, value: int) -> dict:
|
||||||
"""Set the distance threshold at which the PIR sensor is will trigger."""
|
"""Set the distance threshold at which the PIR sensor is will trigger."""
|
||||||
return await self.set_range(value=value)
|
payload = {"index": Range.Custom.value, "value": value}
|
||||||
|
return await self.call("set_trigger_sens", payload)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def inactivity_timeout(self) -> int:
|
def inactivity_timeout(self) -> int:
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
import pytest
|
|
||||||
from pytest_mock import MockerFixture
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
from kasa import Module
|
from kasa import Module
|
||||||
from kasa.exceptions import KasaException
|
|
||||||
from kasa.iot import IotDimmer
|
from kasa.iot import IotDimmer
|
||||||
from kasa.iot.modules.motion import Motion, Range
|
from kasa.iot.modules.motion import Motion, Range
|
||||||
|
|
||||||
@ -38,32 +36,36 @@ async def test_motion_range(dev: IotDimmer, mocker: MockerFixture):
|
|||||||
motion: Motion = dev.modules[Module.IotMotion]
|
motion: Motion = dev.modules[Module.IotMotion]
|
||||||
query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper")
|
query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper")
|
||||||
|
|
||||||
await motion.set_range(value=123)
|
for range in Range:
|
||||||
query_helper.assert_called_with(
|
await motion.set_range(range)
|
||||||
"smartlife.iot.PIR",
|
query_helper.assert_called_with(
|
||||||
"set_trigger_sens",
|
"smartlife.iot.PIR",
|
||||||
{"index": Range.Custom.value, "value": 123},
|
"set_trigger_sens",
|
||||||
)
|
{"index": range.value},
|
||||||
|
)
|
||||||
|
|
||||||
await motion.set_range(range=Range.Custom, value=123)
|
|
||||||
query_helper.assert_called_with(
|
|
||||||
"smartlife.iot.PIR",
|
|
||||||
"set_trigger_sens",
|
|
||||||
{"index": Range.Custom.value, "value": 123},
|
|
||||||
)
|
|
||||||
|
|
||||||
await motion.set_range(range=Range.Far)
|
@dimmer_iot
|
||||||
query_helper.assert_called_with(
|
async def test_motion_threshold(dev: IotDimmer, mocker: MockerFixture):
|
||||||
"smartlife.iot.PIR", "set_trigger_sens", {"index": Range.Far.value}
|
motion: Motion = dev.modules[Module.IotMotion]
|
||||||
)
|
query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper")
|
||||||
|
|
||||||
with pytest.raises(KasaException, match="Refusing to set non-custom range"):
|
for range in Range:
|
||||||
await motion.set_range(range=Range.Near, value=100) # type: ignore[call-overload]
|
# Switch to a given range.
|
||||||
|
await motion.set_range(range)
|
||||||
|
query_helper.assert_called_with(
|
||||||
|
"smartlife.iot.PIR",
|
||||||
|
"set_trigger_sens",
|
||||||
|
{"index": range.value},
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(
|
# Assert that the range always goes to custom, regardless of current range.
|
||||||
KasaException, match="Either range or value needs to be defined"
|
await motion.set_threshold(123)
|
||||||
):
|
query_helper.assert_called_with(
|
||||||
await motion.set_range() # type: ignore[call-overload]
|
"smartlife.iot.PIR",
|
||||||
|
"set_trigger_sens",
|
||||||
|
{"index": Range.Custom.value, "value": 123},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dimmer_iot
|
@dimmer_iot
|
||||||
|
@ -140,7 +140,10 @@ async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture)
|
|||||||
mock_setter.assert_called_with("first")
|
mock_setter.assert_called_with("first")
|
||||||
mock_setter.reset_mock()
|
mock_setter.reset_mock()
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="Unexpected value for dummy_feature: invalid"): # noqa: PT012
|
with pytest.raises( # noqa: PT012
|
||||||
|
ValueError,
|
||||||
|
match="Unexpected value for dummy_feature: 'invalid' (?: - allowed: .*)?",
|
||||||
|
):
|
||||||
await dummy_feature.set_value("invalid")
|
await dummy_feature.set_value("invalid")
|
||||||
assert "Unexpected value" in caplog.text
|
assert "Unexpected value" in caplog.text
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user