8000 Add module support & query their information during update cycle (#243) · python-kasa/python-kasa@3cd196c · GitHub
[go: up one dir, main page]

Skip to content

Commit 3cd196c

Browse files
committed
Add module support & query their information during update cycle (#243)
* Add module support & modularize existing query This creates a base to expose more features on the supported devices. At the moment, the most visible change is that each update cycle gets information from all available modules: * Basic system info * Cloud (new) * Countdown (new) * Antitheft (new) * Schedule (new) * Time (existing, implements the time/timezone handling) * Emeter (existing, partially separated from smartdevice) * Fix imports * Fix linting * Use device host instead of alias in module repr * Add property to list available modules, print them in cli state report * usage: fix the get_realtime query * separate usage from schedule to avoid multi-inheritance * Fix module querying * Add is_supported property to modules
1 parent 6f5a60a commit 3cd196c

17 files changed

+588
-138
lines changed

kasa/cli.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ async def state(ctx, dev: SmartDevice):
221221
click.echo()
222222

223223
click.echo(click.style("\t== Generic information ==", bold=True))
224-
click.echo(f"\tTime: {await dev.get_time()}")
224+
click.echo(f"\tTime: {dev.time} (tz: {dev.timezone}")
225225
click.echo(f"\tHardware: {dev.hw_info['hw_ver']}")
226226
click.echo(f"\tSoftware: {dev.hw_info['sw_ver']}")
227227
click.echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})")
@@ -236,6 +236,13 @@ async def state(ctx, dev: SmartDevice):
236236
emeter_status = dev.emeter_realtime
237237
click.echo(f"\t{emeter_status}")
238238

239+
click.echo(click.style("\n\t== Modules ==", bold=True))
240+
for module in dev.modules.values():
241+
if module.is_supported:
242+
click.echo(click.style(f"\t+ {module}", fg="green"))
243+
else:
244+
click.echo(click.style(f"\t- {module}", fg="red"))
245+
239246

240247
@cli.command()
241248
@pass_dev
@@ -430,7 +437,7 @@ async def led(dev, state):
430437
@pass_dev
431438
async def time(dev):
432439
"""Get the device time."""
433-
res = await dev.get_time()
440+
res = dev.time
434441
click.echo(f"Current time: {res}")
435442
return res
436443

@@ -488,5 +495,23 @@ async def reboot(plug, delay):
488495
return await plug.reboot(delay)
489496

490497

498+
@cli.group()
499+
@pass_dev
500+
async def schedule(dev):
501+
"""Scheduling commands."""
502+
503+
504+
@schedule.command(name="list")
505+
@pass_dev
506+
@click.argument("type", default="schedule")
507+
def _schedule_list(dev, type):
508+
"""Return the list of schedule actions for the given type."""
509+
sched = dev.modules[type]
510+
for rule in sched.rules:
511+
print(rule)
512+
else:
513+
click.echo(f"No rules of type {type}")
514+
515+
491516
if __name__ == "__main__":
492517
cli()

kasa/modules/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# flake8: noqa
2+
from .antitheft import Antitheft
3+
from .cloud import Cloud
4+
from .countdown import Countdown
5+
from .emeter import Emeter
6+
from .module import Module
7+
from .rulemodule import Rule, RuleModule
8+
from .schedule import Schedule
9+
from .time import Time
10+
from .usage import Usage

kasa/modules/antitheft.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Implementation of the antitheft module."""
2+
from .rulemodule import RuleModule
3+
4+
5+
class Antitheft(RuleModule):
6+
"""Implementation of the antitheft module.
7+
8+
This shares the functionality among other rule-based modules.
9+
"""

kasa/modules/cloud.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Cloud module implementation."""
2+
from pydantic import BaseModel
3+
4+
from .module import Module
5+
6+
7+
class CloudInfo(BaseModel):
8+
"""Container for cloud settings."""
9+
10+
binded: bool
11+
cld_connection: int
12+
fwDlPage: str
13+
fwNotifyType: int
14+
illegalType: int
15+
server: str
16+
stopConnect: int
17+
tcspInfo: str
18+
tcspStatus: int
19+
username: str
20+
21+
22+
class Cloud(Module):
23+
"""Module implementing support for cloud services."""
24+
25+
def query(self):
26+
"""Request cloud connectivity info."""
27+
return self.query_for_command("get_info")
28+
29+
@property
30+
def info(self) -> CloudInfo:
31+
"""Return information about the cloud connectivity."""
32+
return CloudInfo.parse_obj(self.data["get_info"])
33+
34+
def get_available_firmwares(self):
35+
"""Return list of available firmwares."""
36+
return self.query_for_command("get_intl_fw_list")
37+
38+
def set_server(self, url: str):
39+
"""Set the update server URL."""
40+
return self.query_for_command("set_server_url", {"server": url})
41+
42+
def connect(self, username: str, password: str):
43+
"""Login to the cloud using given information."""
44+
return self.query_for_command(
45+
"bind", {"username": username, "password": password}
46+
)
47+
48+
def disconnect(self):
49+
"""Disconnect from the cloud."""
50+
return self.query_for_command("unbind")

kasa/modules/countdown.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Implementation for the countdown timer."""
2+
from .rulemodule import RuleModule
3+
4+
5+
class Countdown(RuleModule):
6+
"""Implementation of countdown module."""

kasa/modules/emeter.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Implementation of the emeter module."""
2+
from ..emeterstatus import EmeterStatus
3+
from .usage import Usage
4+
5+
6+
class Emeter(Usage):
7+
"""Emeter module."""
8+
9+
def query(self):
10+
"""Prepare query for emeter data."""
11+
return self._device._create_emeter_request()
12+
13+
@property # type: ignore
14+
def realtime(self) -> EmeterStatus:
15+
"""Return current energy readings."""
16+
return EmeterStatus(self.data["get_realtime"])
17+
18+
async def erase_stats(self):
19+
"""Erase all stats."""
20+
return await self.call("erase_emeter_stat")

kasa/modules/module.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Base class for all module implementations."""
2+
import collections
3+
from abc import ABC, abstractmethod
4+
from typing import TYPE_CHECKING
5+
6+
if TYPE_CHECKING:
7+
from kasa import SmartDevice
8+
9+
10+
# TODO: This is used for query construcing
11+
def merge(d, u):
12+
"""Update dict recursively."""
13+
for k, v in u.items():
14+
if isinstance(v, collections.abc.Mapping):
15+
d[k] = merge(d.get(k, {}), v)
16+
else:
17+
d[k] = v
18+
return d
19+
20+
21+
class Module(ABC):
22+
"""Base class implemention for all modules.
23+
24+
The base classes should implement `query` to return the query they want to be
25+
executed during the regular update cycle.
26+
"""
27+
28+
def __init__(self, device: "SmartDevice", module: str):
29+
self._device: "SmartDevice" = device
30+
self._module = module
31+
32+
@abstractmethod
33+
def query(self):
34+
"""Query to execute during the update cycle.
35+
36+
The inheriting modules implement this to include their wanted
37+
queries to the query that gets executed when Device.update() gets called.
38+
"""
39+
40+
@property
41+
def data(self):
42+
"""Return the module specific raw data from the last update."""
43+
return self._device._last_update[self._module]
44+
45+
@property
46+
def is_supported(self) -> bool:
47+
"""Return whether the module is supported by the device."""
48+
return "err_code" not in self.data
49+
50+
def call(self, method, params=None):
51+
"""Call the given method with the given parameters."""
52+
return self._device._query_helper(self._module, method, params)
53+
54+
def query_for_command(self, query, params=None):
55+
"""Create a request object for the given parameters."""
56+
return self._device._create_request(self._module, query, params)
57+
58+
def __repr__(self) -> str:
59+
return f"<Module {self.__class__.__name__} ({self._module}) for {self._device.host}>"

kasa/modules/rulemodule.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Base implementation for all rule-based modules."""
2+
import logging
3+
from enum import Enum
4+
from typing import Dict, List, Optional
5+
6+
from pydantic import BaseModel
7+
8+
from .module import Module, merge
9+
10+
11+
class Action(Enum):
12+
"""Action to perform."""
13+
14+
Disabled = -1
15+
TurnOff = 0
16+
TurnOn = 1
17+
Unknown = 2
18+
19+
20+
class TimeOption(Enum):
21+
"""Time when the action is executed."""
22+
23+
Disabled = -1
24+
Enabled = 0
25+
AtSunrise = 1
26+
AtSunset = 2
27+
28+
29+
class Rule(BaseModel):
30+
"""Representation of a rule."""
31+
32+
id: str
33+
name: str
34+
enable: bool
35+
wday: List[int]
36+
repeat: bool
37+
38+
# start action
39+
sact: Optional[Action]
40+
stime_opt: TimeOption
41+
smin: int
42+
43+
eact: Optional[Action]
44+
etime_opt: TimeOption
45+
emin: int
46+
47+
# Only on bulbs
48+
s_light: Optional[Dict]
49+
50+
51+
_LOGGER = logging.getLogger(__name__)
52+
53+
54+
class RuleModule(Module):
55+
"""Base class for rule-based modules, such as countdown and antitheft."""
56+
57+
def query(self):
58+
"""Prepare the query for rules."""
59+
q = self.query_for_command("get_rules")
60+
return merge(q, self.query_for_command("get_next_action"))
61+
62+
@property
63+
def rules(self) -> List[Rule]:
64+
"""Return the list of rules for the service."""
65+
try:
66+
return [
67+
Rule.parse_obj(rule) for rule in self.data["get_rules"]["rule_list"]
68+
]
69+
except Exception as ex:
70+
_LOGGER.error("Unable to read rule list: %s (data: %s)", ex, self.data)
71+
return []
72+
73+
async def set_enabled(self, state: bool):
74+
"""Enable or disable the service."""
75+
return await self.call("set_overall_enable", state)
76+
77+
async def delete_rule(self, rule: Rule):
78+
"""Delete the given rule."""
79+
return await self.call("delete_rule", {"id": rule.id})
80+
81+
async def delete_all_rules(self):
82+
"""Delete all rules."""
83+
return await self.call("delete_all_rules")

kasa/modules/schedule.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Schedule module implementation."""
2+
from .rulemodule import RuleModule
3+
4+
5+
class Schedule(RuleModule):
6+
"""Implements the scheduling interface."""

kasa/modules/time.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Provides the current time and timezone information."""
2+
from datetime import datetime
3+
4+
from .module import Module, merge
5+
6+
7+
class Time(Module):
8+
"""Implements the timezone settings."""
9+
10+
def query(self):
11+
"""Request time and timezone."""
12+
q = self.query_for_command("get_time")
13+
14+
merge(q, self.query_for_command("get_timezone"))
15+
return q
16+
17+
@property
18+
def time(self) -> datetime:
19+
"""Return current device time."""
20+
res = self.data["get_time"]
21+
return datetime(
22+
res["year"],
23+
res["month"],
24+
res["mday"],
25+
res["hour"],
26+
res["min"],
27+
res["sec"],
28+
)
29+
30+
@property
31+
def timezone(self):
32+
"""Return current timezone."""
33+
res = self.data["get_timezone"]
34+
return res

0 commit comments

Comments
 (0)
0