mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 11:13:34 +00:00
Use ruff and ruff format (#534)
Replaces the previously used linting and code formatting tools with ruff.
This commit is contained in:
parent
0061668c9f
commit
c431dbb832
8
.flake8
8
.flake8
@ -1,8 +0,0 @@
|
||||
[flake8]
|
||||
exclude = .git,.tox,__pycache__,kasa/tests/newfakes.py,kasa/tests/test_fixtures.py
|
||||
max-line-length = 88
|
||||
per-file-ignores =
|
||||
kasa/tests/*.py:D100,D101,D102,D103,D104,F401
|
||||
docs/source/conf.py:D100,D103
|
||||
ignore = D105, D107, E203, E501, W503
|
||||
max-complexity = 18
|
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@ -26,18 +26,9 @@ jobs:
|
||||
run: |
|
||||
python -m pip install --upgrade pip poetry
|
||||
poetry install
|
||||
- name: "Run pyupgrade"
|
||||
- name: "Linting and code formating (ruff)"
|
||||
run: |
|
||||
poetry run pre-commit run pyupgrade --all-files
|
||||
- name: "Code formating (black)"
|
||||
run: |
|
||||
poetry run pre-commit run black --all-files
|
||||
- name: "Code formating (flake8)"
|
||||
run: |
|
||||
poetry run pre-commit run flake8 --all-files
|
||||
- name: "Order of imports (isort)"
|
||||
run: |
|
||||
poetry run pre-commit run isort --all-files
|
||||
poetry run pre-commit run ruff --all-files
|
||||
- name: "Typing checks (mypy)"
|
||||
run: |
|
||||
poetry run pre-commit run mypy --all-files
|
||||
|
@ -9,28 +9,12 @@ repos:
|
||||
- id: debug-statements
|
||||
- id: check-ast
|
||||
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.4.0
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.1.3
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: ['--py38-plus']
|
||||
|
||||
- repo: https://github.com/python/black
|
||||
rev: 23.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
|
||||
- repo: https://github.com/pycqa/flake8
|
||||
rev: 6.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies: [flake8-docstrings]
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-isort
|
||||
rev: v5.10.1
|
||||
hooks:
|
||||
- id: isort
|
||||
additional_dependencies: [toml]
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.3.0
|
||||
|
@ -1,7 +1,8 @@
|
||||
"""Script that checks if README.md is missing devices that have fixtures."""
|
||||
from kasa.tests.conftest import ALL_DEVICES, BULBS, DIMMERS, LIGHT_STRIPS, PLUGS, STRIPS
|
||||
|
||||
readme = open("README.md").read()
|
||||
with open("README.md") as f:
|
||||
readme = f.read()
|
||||
|
||||
typemap = {
|
||||
"light strips": LIGHT_STRIPS,
|
||||
|
@ -1,7 +1,8 @@
|
||||
"""This script generates devinfo files for the test suite.
|
||||
|
||||
If you have new, yet unsupported device or a device with no devinfo file under kasa/tests/fixtures,
|
||||
feel free to run this script and create a PR to add the file to the repository.
|
||||
If you have new, yet unsupported device or a device with no devinfo file under
|
||||
kasa/tests/fixtures, feel free to run this script and create a PR to add the file
|
||||
to the repository.
|
||||
|
||||
Executing this script will several modules and methods one by one,
|
||||
and finally execute a query to query all of them at once.
|
||||
@ -84,13 +85,13 @@ def cli(host, debug):
|
||||
|
||||
for test_call in items:
|
||||
|
||||
async def _run_query():
|
||||
async def _run_query(test_call):
|
||||
protocol = TPLinkSmartHomeProtocol(host)
|
||||
return await protocol.query({test_call.module: {test_call.method: None}})
|
||||
|
||||
try:
|
||||
click.echo(f"Testing {test_call}..", nl=False)
|
||||
info = asyncio.run(_run_query())
|
||||
info = asyncio.run(_run_query(test_call))
|
||||
resp = info[test_call.module]
|
||||
except Exception as ex:
|
||||
click.echo(click.style(f"FAIL {ex}", fg="red"))
|
||||
|
@ -15,7 +15,7 @@ from kasa.protocol import TPLinkSmartHomeProtocol
|
||||
def read_payloads_from_file(file):
|
||||
"""Read the given pcap file and yield json payloads."""
|
||||
pcap = dpkt.pcap.Reader(file)
|
||||
for ts, pkt in pcap:
|
||||
for _ts, pkt in pcap:
|
||||
eth = Ethernet(pkt)
|
||||
if eth.type != ETH_TYPE_IP:
|
||||
continue
|
||||
@ -44,9 +44,8 @@ def read_payloads_from_file(file):
|
||||
|
||||
try:
|
||||
json_payload = json.loads(decrypted)
|
||||
except (
|
||||
Exception
|
||||
) as ex: # this can happen when the response is split into multiple tcp segments
|
||||
except Exception as ex:
|
||||
# this can happen when the response is split into multiple tcp segments
|
||||
echo(f"[red]Unable to parse payload '{decrypted}', ignoring: {ex}[/red]")
|
||||
continue
|
||||
|
||||
@ -91,7 +90,8 @@ def parse_pcap(file):
|
||||
context_str = f" [ctx: {context}]" if context else ""
|
||||
|
||||
echo(
|
||||
f"[{is_success}] {direction}{context_str} {module}.{cmd}: {pf(response)}"
|
||||
f"[{is_success}] {direction}{context_str} {module}.{cmd}:"
|
||||
f" {pf(response)}"
|
||||
)
|
||||
|
||||
echo(pf(seen_items))
|
||||
|
@ -59,13 +59,13 @@ async def main(addrs, rounds):
|
||||
|
||||
if test_gathered:
|
||||
print("=== Testing using gather on all devices ===")
|
||||
for i in range(rounds):
|
||||
for _i in range(rounds):
|
||||
data.append(await _update_concurrently(devs))
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await asyncio.sleep(5)
|
||||
|
||||
for i in range(rounds):
|
||||
for _i in range(rounds):
|
||||
data.append(await _update_sequentially(devs))
|
||||
await asyncio.sleep(2)
|
||||
|
||||
@ -77,7 +77,7 @@ async def main(addrs, rounds):
|
||||
futs = []
|
||||
data = []
|
||||
locks = {dev: asyncio.Lock() for dev in devs}
|
||||
for i in range(rounds):
|
||||
for _i in range(rounds):
|
||||
for dev in devs:
|
||||
futs.append(asyncio.ensure_future(_update(dev, locks[dev])))
|
||||
|
||||
|
10
kasa/cli.py
10
kasa/cli.py
@ -203,7 +203,8 @@ async def cli(
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# The configuration should be converted to use dictConfig, but this keeps mypy happy for now
|
||||
# The configuration should be converted to use dictConfig,
|
||||
# but this keeps mypy happy for now
|
||||
logging.basicConfig(**logging_config) # type: ignore
|
||||
|
||||
if ctx.invoked_subcommand == "discover":
|
||||
@ -278,7 +279,8 @@ async def join(dev: SmartDevice, ssid, password, keytype):
|
||||
echo(f"Asking the device to connect to {ssid}..")
|
||||
res = await dev.wifi_join(ssid, password, keytype=keytype)
|
||||
echo(
|
||||
f"Response: {res} - if the device is not able to join the network, it will revert back to its previous state."
|
||||
f"Response: {res} - if the device is not able to join the network, "
|
||||
f"it will revert back to its previous state."
|
||||
)
|
||||
|
||||
return res
|
||||
@ -347,9 +349,9 @@ async def discover(ctx, timeout, show_unsupported):
|
||||
|
||||
async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3):
|
||||
"""Discover a device identified by its alias."""
|
||||
for attempt in range(1, attempts):
|
||||
for _attempt in range(1, attempts):
|
||||
found_devs = await Discover.discover(target=target, timeout=timeout)
|
||||
for ip, dev in found_devs.items():
|
||||
for _ip, dev in found_devs.items():
|
||||
if dev.alias.lower() == alias.lower():
|
||||
host = dev.host
|
||||
return host
|
||||
|
@ -87,7 +87,7 @@ class _DiscoverProtocol(asyncio.DatagramProtocol):
|
||||
req = json_dumps(Discover.DISCOVERY_QUERY)
|
||||
_LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY)
|
||||
encrypted_req = TPLinkSmartHomeProtocol.encrypt(req)
|
||||
for i in range(self.discovery_packets):
|
||||
for _i in range(self.discovery_packets):
|
||||
self.transport.sendto(encrypted_req[4:], self.target) # type: ignore
|
||||
self.transport.sendto(Discover.DISCOVERY_QUERY_2, self.target_2) # type: ignore
|
||||
|
||||
@ -169,7 +169,8 @@ class Discover:
|
||||
>>> [dev.alias for dev in found_devices]
|
||||
['TP-LINK_Power Strip_CF69']
|
||||
|
||||
Discovery can also be targeted to a specific broadcast address instead of the 255.255.255.255:
|
||||
Discovery can also be targeted to a specific broadcast address instead of
|
||||
the default 255.255.255.255:
|
||||
|
||||
>>> asyncio.run(Discover.discover(target="192.168.8.255"))
|
||||
|
||||
@ -207,14 +208,19 @@ class Discover:
|
||||
Sends discovery message to 255.255.255.255:9999 in order
|
||||
to detect available supported devices in the local network,
|
||||
and waits for given timeout for answers from devices.
|
||||
If you have multiple interfaces, you can use target parameter to specify the network for discovery.
|
||||
If you have multiple interfaces,
|
||||
you can use *target* parameter to specify the network for discovery.
|
||||
|
||||
If given, `on_discovered` coroutine will get awaited with a :class:`SmartDevice`-derived object as parameter.
|
||||
If given, `on_discovered` coroutine will get awaited with
|
||||
a :class:`SmartDevice`-derived object as parameter.
|
||||
|
||||
The results of the discovery are returned as a dict of :class:`SmartDevice`-derived objects keyed with IP addresses.
|
||||
The devices are already initialized and all but emeter-related properties can be accessed directly.
|
||||
The results of the discovery are returned as a dict of
|
||||
:class:`SmartDevice`-derived objects keyed with IP addresses.
|
||||
The devices are already initialized and all but emeter-related properties
|
||||
can be accessed directly.
|
||||
|
||||
:param target: The target address where to send the broadcast discovery queries if multi-homing (e.g. 192.168.xxx.255).
|
||||
:param target: The target address where to send the broadcast discovery
|
||||
queries if multi-homing (e.g. 192.168.xxx.255).
|
||||
:param on_discovered: coroutine to execute on discovery
|
||||
:param timeout: How long to wait for responses, defaults to 5
|
||||
:param discovery_packets: Number of discovery packets to broadcast
|
||||
@ -232,7 +238,7 @@ class Discover:
|
||||
credentials=credentials,
|
||||
timeout=timeout,
|
||||
),
|
||||
local_addr=("0.0.0.0", 0),
|
||||
local_addr=("0.0.0.0", 0), # noqa: S104
|
||||
)
|
||||
protocol = cast(_DiscoverProtocol, protocol)
|
||||
|
||||
@ -275,7 +281,7 @@ class Discover:
|
||||
credentials=credentials,
|
||||
timeout=timeout,
|
||||
),
|
||||
local_addr=("0.0.0.0", 0),
|
||||
local_addr=("0.0.0.0", 0), # noqa: S104
|
||||
)
|
||||
protocol = cast(_DiscoverProtocol, protocol)
|
||||
|
||||
@ -284,10 +290,10 @@ class Discover:
|
||||
|
||||
async with asyncio_timeout(timeout):
|
||||
await event.wait()
|
||||
except asyncio.TimeoutError:
|
||||
except asyncio.TimeoutError as ex:
|
||||
raise SmartDeviceException(
|
||||
f"Timed out getting discovery response for {host}"
|
||||
)
|
||||
) from ex
|
||||
finally:
|
||||
transport.close()
|
||||
|
||||
|
@ -48,9 +48,13 @@ class EmeterStatus(dict):
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmeterStatus power={self.power} voltage={self.voltage} current={self.current} total={self.total}>"
|
||||
return (
|
||||
f"<EmeterStatus power={self.power} voltage={self.voltage}"
|
||||
f" current={self.current} total={self.total}>"
|
||||
)
|
||||
|
||||
def __getitem__(self, item):
|
||||
"""Return value in wanted units."""
|
||||
valid_keys = [
|
||||
"voltage_mv",
|
||||
"power_mw",
|
||||
@ -65,7 +69,7 @@ class EmeterStatus(dict):
|
||||
]
|
||||
|
||||
# 1. if requested data is available, return it
|
||||
if item in super().keys():
|
||||
if item in super().keys(): # noqa: SIM118
|
||||
return super().__getitem__(item)
|
||||
# otherwise decide how to convert it
|
||||
else:
|
||||
@ -74,7 +78,7 @@ class EmeterStatus(dict):
|
||||
if "_" in item: # upscale
|
||||
return super().__getitem__(item[: item.find("_")]) * 1000
|
||||
else: # downscale
|
||||
for i in super().keys():
|
||||
for i in super().keys(): # noqa: SIM118
|
||||
if i.startswith(item):
|
||||
return self.__getitem__(i) / 1000
|
||||
|
||||
|
@ -44,13 +44,19 @@ class Emeter(Usage):
|
||||
return await self.call("get_realtime")
|
||||
|
||||
async def get_daystat(self, *, year=None, month=None, kwh=True) -> Dict:
|
||||
"""Return daily stats for the given year & month as a dictionary of {day: energy, ...}."""
|
||||
"""Return daily stats for the given year & month.
|
||||
|
||||
The return value is a dictionary of {day: energy, ...}.
|
||||
"""
|
||||
data = await self.get_raw_daystat(year=year, month=month)
|
||||
data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh)
|
||||
return data
|
||||
|
||||
async def get_monthstat(self, *, year=None, kwh=True) -> Dict:
|
||||
"""Return monthly stats for the given year as a dictionary of {month: energy, ...}."""
|
||||
"""Return monthly stats for the given year.
|
||||
|
||||
The return value is a dictionary of {month: energy, ...}.
|
||||
"""
|
||||
data = await self.get_raw_monthstat(year=year)
|
||||
data = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh)
|
||||
return data
|
||||
|
@ -57,7 +57,8 @@ class Module(ABC):
|
||||
"""Return the module specific raw data from the last update."""
|
||||
if self._module not in self._device._last_update:
|
||||
raise SmartDeviceException(
|
||||
f"You need to call update() prior accessing module data for '{self._module}'"
|
||||
f"You need to call update() prior accessing module data"
|
||||
f" for '{self._module}'"
|
||||
)
|
||||
|
||||
return self._device._last_update[self._module]
|
||||
@ -80,4 +81,7 @@ class Module(ABC):
|
||||
return self._device._create_request(self._module, query, params)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Module {self.__class__.__name__} ({self._module}) for {self._device.host}>"
|
||||
return (
|
||||
f"<Module {self.__class__.__name__} ({self._module})"
|
||||
f" for {self._device.host}>"
|
||||
)
|
||||
|
@ -73,13 +73,19 @@ class Usage(Module):
|
||||
return await self.call("get_monthstat", {"year": year})
|
||||
|
||||
async def get_daystat(self, *, year=None, month=None) -> Dict:
|
||||
"""Return daily stats for the given year & month as a dictionary of {day: time, ...}."""
|
||||
"""Return daily stats for the given year & month.
|
||||
|
||||
The return value is a dictionary of {day: time, ...}.
|
||||
"""
|
||||
data = await self.get_raw_daystat(year=year, month=month)
|
||||
data = self._convert_stat_data(data["day_list"], entry_key="day")
|
||||
return data
|
||||
|
||||
async def get_monthstat(self, *, year=None) -> Dict:
|
||||
"""Return monthly stats for the given year as a dictionary of {month: time, ...}."""
|
||||
"""Return monthly stats for the given year.
|
||||
|
||||
The return value is a dictionary of {month: time, ...}.
|
||||
"""
|
||||
data = await self.get_raw_monthstat(year=year)
|
||||
data = self._convert_stat_data(data["month_list"], entry_key="month")
|
||||
return data
|
||||
|
@ -60,7 +60,7 @@ class TPLinkSmartHomeProtocol:
|
||||
"""
|
||||
if isinstance(request, dict):
|
||||
request = json_dumps(request)
|
||||
assert isinstance(request, str)
|
||||
assert isinstance(request, str) # noqa: S101
|
||||
|
||||
async with self.query_lock:
|
||||
return await self._query(request, retry_count, self.timeout)
|
||||
@ -77,8 +77,8 @@ class TPLinkSmartHomeProtocol:
|
||||
|
||||
async def _execute_query(self, request: str) -> Dict:
|
||||
"""Execute a query on the device and wait for the response."""
|
||||
assert self.writer is not None
|
||||
assert self.reader is not None
|
||||
assert self.writer is not None # noqa: S101
|
||||
assert self.reader is not None # noqa: S101
|
||||
debug_log = _LOGGER.isEnabledFor(logging.DEBUG)
|
||||
|
||||
if debug_log:
|
||||
@ -116,11 +116,11 @@ class TPLinkSmartHomeProtocol:
|
||||
# Most of the time we will already be connected if the device is online
|
||||
# and the connect call will do nothing and return right away
|
||||
#
|
||||
# However, if we get an unrecoverable error (_NO_RETRY_ERRORS and ConnectionRefusedError)
|
||||
# we do not want to keep trying since many connection open/close operations
|
||||
# in the same time frame can block the event loop. This is especially
|
||||
# import when there are multiple tplink devices being polled.
|
||||
#
|
||||
# However, if we get an unrecoverable error (_NO_RETRY_ERRORS and
|
||||
# ConnectionRefusedError) we do not want to keep trying since many
|
||||
# connection open/close operations in the same time frame can block
|
||||
# the event loop.
|
||||
# This is especially import when there are multiple tplink devices being polled.
|
||||
for retry in range(retry_count + 1):
|
||||
try:
|
||||
await self._connect(timeout)
|
||||
@ -128,26 +128,28 @@ class TPLinkSmartHomeProtocol:
|
||||
await self.close()
|
||||
raise SmartDeviceException(
|
||||
f"Unable to connect to the device: {self.host}:{self.port}: {ex}"
|
||||
)
|
||||
) from ex
|
||||
except OSError as ex:
|
||||
await self.close()
|
||||
if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count:
|
||||
raise SmartDeviceException(
|
||||
f"Unable to connect to the device: {self.host}:{self.port}: {ex}"
|
||||
)
|
||||
f"Unable to connect to the device:"
|
||||
f" {self.host}:{self.port}: {ex}"
|
||||
) from ex
|
||||
continue
|
||||
except Exception as ex:
|
||||
await self.close()
|
||||
if retry >= retry_count:
|
||||
_LOGGER.debug("Giving up on %s after %s retries", self.host, retry)
|
||||
raise SmartDeviceException(
|
||||
f"Unable to connect to the device: {self.host}:{self.port}: {ex}"
|
||||
)
|
||||
f"Unable to connect to the device:"
|
||||
f" {self.host}:{self.port}: {ex}"
|
||||
) from ex
|
||||
continue
|
||||
|
||||
try:
|
||||
assert self.reader is not None
|
||||
assert self.writer is not None
|
||||
assert self.reader is not None # noqa: S101
|
||||
assert self.writer is not None # noqa: S101
|
||||
async with asyncio_timeout(timeout):
|
||||
return await self._execute_query(request)
|
||||
except Exception as ex:
|
||||
|
@ -59,7 +59,8 @@ class TurnOnBehavior(BaseModel):
|
||||
"""Model to present a single turn on behavior.
|
||||
|
||||
:param int preset: the index number of wanted preset.
|
||||
:param BehaviorMode mode: last status or preset mode. If you are changing existing settings, you should not set this manually.
|
||||
:param BehaviorMode mode: last status or preset mode.
|
||||
If you are changing existing settings, you should not set this manually.
|
||||
|
||||
To change the behavior, it is only necessary to change the :attr:`preset` field
|
||||
to contain either the preset index, or ``None`` for the last known state.
|
||||
@ -121,9 +122,11 @@ class SmartBulb(SmartDevice):
|
||||
This will allow accessing the properties using the exposed properties.
|
||||
|
||||
All changes to the device are done using awaitable methods,
|
||||
which will not change the cached values, but you must await :func:`update()` separately.
|
||||
which will not change the cached values,
|
||||
so you must await :func:`update()` to fetch updates values from the device.
|
||||
|
||||
Errors reported by the device are raised as :class:`SmartDeviceExceptions <kasa.exceptions.SmartDeviceException>`,
|
||||
Errors reported by the device are raised as
|
||||
:class:`SmartDeviceExceptions <kasa.exceptions.SmartDeviceException>`,
|
||||
and should be handled by the user of the library.
|
||||
|
||||
Examples:
|
||||
@ -159,7 +162,7 @@ class SmartBulb(SmartDevice):
|
||||
>>> bulb.brightness
|
||||
50
|
||||
|
||||
Bulbs supporting color temperature can be queried to know which range is accepted:
|
||||
Bulbs supporting color temperature can be queried for the supported range:
|
||||
|
||||
>>> bulb.valid_temperature_range
|
||||
ColorTempRange(min=2500, max=9000)
|
||||
@ -175,9 +178,18 @@ class SmartBulb(SmartDevice):
|
||||
>>> bulb.hsv
|
||||
HSV(hue=180, saturation=100, value=80)
|
||||
|
||||
If you don't want to use the default transitions, you can pass `transition` in milliseconds.
|
||||
This applies to all transitions (:func:`turn_on`, :func:`turn_off`, :func:`set_hsv`, :func:`set_color_temp`, :func:`set_brightness`) if supported by the device.
|
||||
Light strips (e.g., KL420L5) do not support this feature, but silently ignore the parameter.
|
||||
If you don't want to use the default transitions,
|
||||
you can pass `transition` in milliseconds.
|
||||
All methods changing the state of the device support this parameter:
|
||||
|
||||
* :func:`turn_on`
|
||||
* :func:`turn_off`
|
||||
* :func:`set_hsv`
|
||||
* :func:`set_color_temp`
|
||||
* :func:`set_brightness`
|
||||
|
||||
Light strips (e.g., KL420L5) do not support this feature,
|
||||
but silently ignore the parameter.
|
||||
The following changes the brightness over a period of 10 seconds:
|
||||
|
||||
>>> asyncio.run(bulb.set_brightness(100, transition=10_000))
|
||||
@ -187,7 +199,8 @@ class SmartBulb(SmartDevice):
|
||||
>>> bulb.presets
|
||||
[SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)]
|
||||
|
||||
To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset` instance to :func:`save_preset` method:
|
||||
To modify an existing preset, pass :class:`~kasa.smartbulb.SmartBulbPreset`
|
||||
instance to :func:`save_preset` method:
|
||||
|
||||
>>> preset = bulb.presets[0]
|
||||
>>> preset.brightness
|
||||
@ -197,7 +210,7 @@ class SmartBulb(SmartDevice):
|
||||
>>> bulb.presets[0].brightness
|
||||
100
|
||||
|
||||
"""
|
||||
""" # noqa: E501
|
||||
|
||||
LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice"
|
||||
SET_LIGHT_METHOD = "transition_light_state"
|
||||
@ -362,9 +375,7 @@ class SmartBulb(SmartDevice):
|
||||
|
||||
def _raise_for_invalid_brightness(self, value):
|
||||
if not isinstance(value, int) or not (0 <= value <= 100):
|
||||
raise ValueError(
|
||||
"Invalid brightness value: {} " "(valid range: 0-100%)".format(value)
|
||||
)
|
||||
raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)")
|
||||
|
||||
@requires_update
|
||||
async def set_hsv(
|
||||
@ -386,14 +397,11 @@ class SmartBulb(SmartDevice):
|
||||
raise SmartDeviceException("Bulb does not support color.")
|
||||
|
||||
if not isinstance(hue, int) or not (0 <= hue <= 360):
|
||||
raise ValueError(
|
||||
"Invalid hue value: {} " "(valid range: 0-360)".format(hue)
|
||||
)
|
||||
raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)")
|
||||
|
||||
if not isinstance(saturation, int) or not (0 <= saturation <= 100):
|
||||
raise ValueError(
|
||||
"Invalid saturation value: {} "
|
||||
"(valid range: 0-100%)".format(saturation)
|
||||
f"Invalid saturation value: {saturation} (valid range: 0-100%)"
|
||||
)
|
||||
|
||||
light_state = {
|
||||
@ -433,8 +441,9 @@ class SmartBulb(SmartDevice):
|
||||
valid_temperature_range = self.valid_temperature_range
|
||||
if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]:
|
||||
raise ValueError(
|
||||
"Temperature should be between {} "
|
||||
"and {}, was {}".format(*valid_temperature_range, temp)
|
||||
"Temperature should be between {} and {}, was {}".format(
|
||||
*valid_temperature_range, temp
|
||||
)
|
||||
)
|
||||
|
||||
light_state = {"color_temp": temp}
|
||||
|
@ -101,8 +101,8 @@ def _parse_features(features: str) -> Set[str]:
|
||||
class SmartDevice:
|
||||
"""Base class for all supported device types.
|
||||
|
||||
You don't usually want to construct this class which implements the shared common interfaces.
|
||||
The recommended way is to either use the discovery functionality, or construct one of the subclasses:
|
||||
You don't usually want to initialize this class manually,
|
||||
but either use :class:`Discover` class, or use one of the subclasses:
|
||||
|
||||
* :class:`SmartPlug`
|
||||
* :class:`SmartBulb`
|
||||
@ -145,7 +145,8 @@ class SmartDevice:
|
||||
>>> dev.mac
|
||||
01:23:45:67:89:ab
|
||||
|
||||
When initialized using discovery or using a subclass, you can check the type of the device:
|
||||
When initialized using discovery or using a subclass,
|
||||
you can check the type of the device:
|
||||
|
||||
>>> dev.is_bulb
|
||||
False
|
||||
@ -154,7 +155,8 @@ class SmartDevice:
|
||||
>>> dev.is_plug
|
||||
True
|
||||
|
||||
You can also get the hardware and software as a dict, or access the full device response:
|
||||
You can also get the hardware and software as a dict,
|
||||
or access the full device response:
|
||||
|
||||
>>> dev.hw_info
|
||||
{'sw_ver': '1.2.5 Build 171213 Rel.101523',
|
||||
@ -175,7 +177,8 @@ class SmartDevice:
|
||||
>>> dev.is_on
|
||||
True
|
||||
|
||||
Some devices provide energy consumption meter, and regular update will already fetch some information:
|
||||
Some devices provide energy consumption meter,
|
||||
and regular update will already fetch some information:
|
||||
|
||||
>>> dev.has_emeter
|
||||
True
|
||||
@ -184,7 +187,8 @@ class SmartDevice:
|
||||
>>> dev.emeter_today
|
||||
>>> dev.emeter_this_month
|
||||
|
||||
You can also query the historical data (note that these needs to be awaited), keyed with month/day:
|
||||
You can also query the historical data (note that these needs to be awaited),
|
||||
keyed with month/day:
|
||||
|
||||
>>> asyncio.run(dev.get_emeter_monthly(year=2016))
|
||||
{11: 1.089, 12: 1.582}
|
||||
@ -214,9 +218,9 @@ class SmartDevice:
|
||||
self.credentials = credentials
|
||||
_LOGGER.debug("Initializing %s of type %s", self.host, type(self))
|
||||
self._device_type = DeviceType.Unknown
|
||||
# TODO: typing Any is just as using Optional[Dict] would require separate checks in
|
||||
# accessors. the @updated_required decorator does not ensure mypy that these
|
||||
# are not accessed incorrectly.
|
||||
# TODO: typing Any is just as using Optional[Dict] would require separate
|
||||
# checks in accessors. the @updated_required decorator does not ensure
|
||||
# mypy that these are not accessed incorrectly.
|
||||
self._last_update: Any = None
|
||||
self._sys_info: Any = None # TODO: this is here to avoid changing tests
|
||||
self._features: Set[str] = set()
|
||||
@ -230,8 +234,6 @@ class SmartDevice:
|
||||
_LOGGER.debug("Module %s already registered, ignoring..." % name)
|
||||
return
|
||||
|
||||
assert name not in self.modules
|
||||
|
||||
_LOGGER.debug("Adding module %s", module)
|
||||
self.modules[name] = module
|
||||
|
||||
@ -751,4 +753,8 @@ class SmartDevice:
|
||||
def __repr__(self):
|
||||
if self._last_update is None:
|
||||
return f"<{self._device_type} at {self.host} - update() needed>"
|
||||
return f"<{self._device_type} model {self.model} at {self.host} ({self.alias}), is_on: {self.is_on} - dev specific: {self.state_information}>"
|
||||
return (
|
||||
f"<{self._device_type} model {self.model} at {self.host}"
|
||||
f" ({self.alias}), is_on: {self.is_on}"
|
||||
f" - dev specific: {self.state_information}>"
|
||||
)
|
||||
|
@ -41,7 +41,8 @@ class SmartDimmer(SmartPlug):
|
||||
This will allow accessing the properties using the exposed properties.
|
||||
|
||||
All changes to the device are done using awaitable methods,
|
||||
which will not change the cached values, but you must await :func:`update()` separately.
|
||||
which will not change the cached values,
|
||||
but you must await :func:`update()` separately.
|
||||
|
||||
Errors reported by the device are raised as :class:`SmartDeviceException`\s,
|
||||
and should be handled by the user of the library.
|
||||
@ -74,7 +75,7 @@ class SmartDimmer(SmartPlug):
|
||||
super().__init__(host, port=port, credentials=credentials, timeout=timeout)
|
||||
self._device_type = DeviceType.Dimmer
|
||||
# TODO: need to be verified if it's okay to call these on HS220 w/o these
|
||||
# TODO: need to be figured out what's the best approach to detect support for these
|
||||
# TODO: need to be figured out what's the best approach to detect support
|
||||
self.add_module("motion", Motion(self, "smartlife.iot.PIR"))
|
||||
self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS"))
|
||||
|
||||
@ -187,7 +188,8 @@ class SmartDimmer(SmartPlug):
|
||||
"""Set action to perform on button click/hold.
|
||||
|
||||
:param action_type ActionType: whether to control double click or hold action.
|
||||
:param action ButtonAction: what should the button do (nothing, instant, gentle, change preset)
|
||||
:param action ButtonAction: what should the button do
|
||||
(nothing, instant, gentle, change preset)
|
||||
:param index int: in case of preset change, the preset to select
|
||||
"""
|
||||
action_type_setter = f"set_{action_type}"
|
||||
|
@ -105,9 +105,11 @@ class SmartLightStrip(SmartBulb):
|
||||
) -> None:
|
||||
"""Set an effect on the device.
|
||||
|
||||
If brightness or transition is defined, its value will be used instead of the effect-specific default.
|
||||
If brightness or transition is defined,
|
||||
its value will be used instead of the effect-specific default.
|
||||
|
||||
See :meth:`effect_list` for available effects, or use :meth:`set_custom_effect` for custom effects.
|
||||
See :meth:`effect_list` for available effects,
|
||||
or use :meth:`set_custom_effect` for custom effects.
|
||||
|
||||
:param str effect: The effect to set
|
||||
:param int brightness: The wanted brightness
|
||||
|
@ -16,7 +16,8 @@ class SmartPlug(SmartDevice):
|
||||
This will allow accessing the properties using the exposed properties.
|
||||
|
||||
All changes to the device are done using awaitable methods,
|
||||
which will not change the cached values, but you must await :func:`update()` separately.
|
||||
which will not change the cached values,
|
||||
but you must await :func:`update()` separately.
|
||||
|
||||
Errors reported by the device are raised as :class:`SmartDeviceException`\s,
|
||||
and should be handled by the user of the library.
|
||||
|
@ -40,7 +40,8 @@ class SmartStrip(SmartDevice):
|
||||
This will allow accessing the properties using the exposed properties.
|
||||
|
||||
All changes to the device are done using awaitable methods,
|
||||
which will not change the cached values, but you must await :func:`update()` separately.
|
||||
which will not change the cached values,
|
||||
but you must await :func:`update()` separately.
|
||||
|
||||
Errors reported by the device are raised as :class:`SmartDeviceException`\s,
|
||||
and should be handled by the user of the library.
|
||||
|
@ -1,8 +1,16 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from voluptuous import Coerce # type: ignore
|
||||
from voluptuous import REMOVE_EXTRA, All, Any, Invalid, Optional, Range, Schema
|
||||
from voluptuous import (
|
||||
REMOVE_EXTRA,
|
||||
All,
|
||||
Any,
|
||||
Coerce, # type: ignore
|
||||
Invalid,
|
||||
Optional,
|
||||
Range,
|
||||
Schema,
|
||||
)
|
||||
|
||||
from ..protocol import TPLinkSmartHomeProtocol
|
||||
|
||||
@ -305,7 +313,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
|
||||
|
||||
self.proto = proto
|
||||
|
||||
def set_alias(self, x, child_ids=[]):
|
||||
def set_alias(self, x, child_ids=None):
|
||||
if child_ids is None:
|
||||
child_ids = []
|
||||
_LOGGER.debug("Setting alias to %s, child_ids: %s", x["alias"], child_ids)
|
||||
if child_ids:
|
||||
for child in self.proto["system"]["get_sysinfo"]["children"]:
|
||||
@ -314,7 +324,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
|
||||
else:
|
||||
self.proto["system"]["get_sysinfo"]["alias"] = x["alias"]
|
||||
|
||||
def set_relay_state(self, x, child_ids=[]):
|
||||
def set_relay_state(self, x, child_ids=None):
|
||||
if child_ids is None:
|
||||
child_ids = []
|
||||
_LOGGER.debug("Setting relay state to %s", x["state"])
|
||||
|
||||
if not child_ids and "children" in self.proto["system"]["get_sysinfo"]:
|
||||
@ -362,10 +374,8 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
|
||||
_LOGGER.debug("Current light state: %s", light_state)
|
||||
new_state = light_state
|
||||
|
||||
if state_changes["on_off"] == 1: # turn on requested
|
||||
if not light_state[
|
||||
"on_off"
|
||||
]: # if we were off, use the dft_on_state as a base
|
||||
# turn on requested, if we were off, use the dft_on_state as a base
|
||||
if state_changes["on_off"] == 1 and not light_state["on_off"]:
|
||||
_LOGGER.debug("Bulb was off, using dft_on_state")
|
||||
new_state = light_state["dft_on_state"]
|
||||
|
||||
@ -384,7 +394,7 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
|
||||
self.proto["system"]["get_sysinfo"]["light_state"] = new_state
|
||||
|
||||
def set_preferred_state(self, new_state, *args):
|
||||
"""Implementation of set_preferred_state."""
|
||||
"""Implement set_preferred_state."""
|
||||
self.proto["system"]["get_sysinfo"]["preferred_state"][
|
||||
new_state["index"]
|
||||
] = new_state
|
||||
@ -459,11 +469,11 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol):
|
||||
child_ids = []
|
||||
|
||||
def get_response_for_module(target):
|
||||
if target not in proto.keys():
|
||||
if target not in proto:
|
||||
return error(msg="target not found")
|
||||
|
||||
def get_response_for_command(cmd):
|
||||
if cmd not in proto[target].keys():
|
||||
if cmd not in proto[target]:
|
||||
return error(msg=f"command {cmd} not found")
|
||||
|
||||
params = request[target][cmd]
|
||||
|
@ -156,7 +156,7 @@ async def test_credentials(discovery_data: dict, mocker):
|
||||
mocker.patch("kasa.cli.state", new=_state)
|
||||
|
||||
# Get the type string parameter from the discovery_info
|
||||
for cli_device_type in {
|
||||
for cli_device_type in { # noqa: B007
|
||||
i
|
||||
for i in TYPE_TO_CLASS
|
||||
if TYPE_TO_CLASS[i] == Discover._get_device_class(discovery_data)
|
||||
|
@ -71,7 +71,7 @@ async def test_discover_single(discovery_data: dict, mocker, custom_port):
|
||||
x = await Discover.discover_single(host, port=custom_port)
|
||||
assert issubclass(x.__class__, SmartDevice)
|
||||
assert x._sys_info is not None
|
||||
assert x.port == custom_port or 9999
|
||||
assert x.port == custom_port or x.port == 9999
|
||||
|
||||
|
||||
@pytest.mark.parametrize("custom_port", [123, None])
|
||||
@ -82,7 +82,7 @@ async def test_connect_single(discovery_data: dict, mocker, custom_port):
|
||||
|
||||
dev = await Discover.connect_single(host, port=custom_port)
|
||||
assert issubclass(dev.__class__, SmartDevice)
|
||||
assert dev.port == custom_port or 9999
|
||||
assert dev.port == custom_port or dev.port == 9999
|
||||
|
||||
|
||||
async def test_connect_single_query_fails(discovery_data: dict, mocker):
|
||||
|
@ -28,8 +28,9 @@ async def test_state_info(dev):
|
||||
|
||||
@pytest.mark.requires_dummy
|
||||
async def test_invalid_connection(dev):
|
||||
with patch.object(FakeTransportProtocol, "query", side_effect=SmartDeviceException):
|
||||
with pytest.raises(SmartDeviceException):
|
||||
with patch.object(
|
||||
FakeTransportProtocol, "query", side_effect=SmartDeviceException
|
||||
), pytest.raises(SmartDeviceException):
|
||||
await dev.update()
|
||||
|
||||
|
||||
|
@ -54,12 +54,6 @@ coverage = {version = "*", extras = ["toml"]}
|
||||
docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"]
|
||||
speedups = ["orjson", "kasa-crypt"]
|
||||
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
known_first_party = "kasa"
|
||||
known_third_party = ["asyncclick", "pytest", "setuptools", "voluptuous"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["kasa"]
|
||||
branch = true
|
||||
@ -72,15 +66,6 @@ exclude_lines = [
|
||||
"def __repr__"
|
||||
]
|
||||
|
||||
[tool.interrogate]
|
||||
ignore-init-method = true
|
||||
ignore-magic = true
|
||||
ignore-private = true
|
||||
ignore-semiprivate = true
|
||||
fail-under = 100
|
||||
exclude = ['kasa/tests/*']
|
||||
verbose = 2
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"requires_dummy: test requires dummy data to pass, skipped on real devices",
|
||||
@ -95,3 +80,39 @@ ignore-path-errors = ["docs/source/index.rst;D000"]
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py38"
|
||||
select = [
|
||||
"E", # pycodestyle
|
||||
"D", # pydocstyle
|
||||
"F", # pyflakes
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"SIM", # flake8-simplify
|
||||
"I", # isort
|
||||
"S", # bandit
|
||||
]
|
||||
ignore = [
|
||||
"D105", # Missing docstring in magic method
|
||||
"D107", # Missing docstring in `__init__`
|
||||
]
|
||||
|
||||
[tool.ruff.pydocstyle]
|
||||
convention = "pep257"
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
"kasa/tests/*.py" = [
|
||||
"D100",
|
||||
"D101",
|
||||
"D102",
|
||||
"D103",
|
||||
"D104",
|
||||
"F401",
|
||||
"S101", # allow asserts
|
||||
"E501", # ignore line-too-longs
|
||||
]
|
||||
"docs/source/conf.py" = [
|
||||
"D100",
|
||||
"D103",
|
||||
]
|
||||
|
8
tox.ini
8
tox.ini
@ -1,5 +1,5 @@
|
||||
[tox]
|
||||
envlist=py37,py38,flake8,lint,coverage
|
||||
envlist=py37,py38,lint,coverage
|
||||
skip_missing_interpreters = True
|
||||
isolated_build = True
|
||||
|
||||
@ -31,12 +31,6 @@ commands =
|
||||
coverage report
|
||||
coverage html
|
||||
|
||||
[testenv:flake8]
|
||||
deps=
|
||||
flake8
|
||||
flake8-docstrings
|
||||
commands=flake8 kasa
|
||||
|
||||
[testenv:lint]
|
||||
deps = pre-commit
|
||||
skip_install = true
|
||||
|
Loading…
Reference in New Issue
Block a user