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
This commit is contained in:
Teemu R
2021-11-07 02:41:12 +01:00
parent 7b9e3aae8a
commit 3926f3224f
17 changed files with 587 additions and 137 deletions

10
kasa/modules/__init__.py Normal file
View File

@@ -0,0 +1,10 @@
# flake8: noqa
from .antitheft import Antitheft
from .cloud import Cloud
from .countdown import Countdown
from .emeter import Emeter
from .module import Module
from .rulemodule import Rule, RuleModule
from .schedule import Schedule
from .time import Time
from .usage import Usage

View File

@@ -0,0 +1,9 @@
"""Implementation of the antitheft module."""
from .rulemodule import RuleModule
class Antitheft(RuleModule):
"""Implementation of the antitheft module.
This shares the functionality among other rule-based modules.
"""

50
kasa/modules/cloud.py Normal file
View File

@@ -0,0 +1,50 @@
"""Cloud module implementation."""
from pydantic import BaseModel
from .module import Module
class CloudInfo(BaseModel):
"""Container for cloud settings."""
binded: bool
cld_connection: int
fwDlPage: str
fwNotifyType: int
illegalType: int
server: str
stopConnect: int
tcspInfo: str
tcspStatus: int
username: str
class Cloud(Module):
"""Module implementing support for cloud services."""
def query(self):
"""Request cloud connectivity info."""
return self.query_for_command("get_info")
@property
def info(self) -> CloudInfo:
"""Return information about the cloud connectivity."""
return CloudInfo.parse_obj(self.data["get_info"])
def get_available_firmwares(self):
"""Return list of available firmwares."""
return self.query_for_command("get_intl_fw_list")
def set_server(self, url: str):
"""Set the update server URL."""
return self.query_for_command("set_server_url", {"server": url})
def connect(self, username: str, password: str):
"""Login to the cloud using given information."""
return self.query_for_command(
"bind", {"username": username, "password": password}
)
def disconnect(self):
"""Disconnect from the cloud."""
return self.query_for_command("unbind")

View File

@@ -0,0 +1,6 @@
"""Implementation for the countdown timer."""
from .rulemodule import RuleModule
class Countdown(RuleModule):
"""Implementation of countdown module."""

20
kasa/modules/emeter.py Normal file
View File

@@ -0,0 +1,20 @@
"""Implementation of the emeter module."""
from ..emeterstatus import EmeterStatus
from .usage import Usage
class Emeter(Usage):
"""Emeter module."""
def query(self):
"""Prepare query for emeter data."""
return self._device._create_emeter_request()
@property # type: ignore
def realtime(self) -> EmeterStatus:
"""Return current energy readings."""
return EmeterStatus(self.data["get_realtime"])
async def erase_stats(self):
"""Erase all stats."""
return await self.call("erase_emeter_stat")

59
kasa/modules/module.py Normal file
View File

@@ -0,0 +1,59 @@
"""Base class for all module implementations."""
import collections
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from kasa import SmartDevice
# TODO: This is used for query construcing
def merge(d, u):
"""Update dict recursively."""
for k, v in u.items():
if isinstance(v, collections.abc.Mapping):
d[k] = merge(d.get(k, {}), v)
else:
d[k] = v
return d
class Module(ABC):
"""Base class implemention for all modules.
The base classes should implement `query` to return the query they want to be
executed during the regular update cycle.
"""
def __init__(self, device: "SmartDevice", module: str):
self._device: "SmartDevice" = device
self._module = module
@abstractmethod
def query(self):
"""Query to execute during the update cycle.
The inheriting modules implement this to include their wanted
queries to the query that gets executed when Device.update() gets called.
"""
@property
def data(self):
"""Return the module specific raw data from the last update."""
return self._device._last_update[self._module]
@property
def is_supported(self) -> bool:
"""Return whether the module is supported by the device."""
return "err_code" not in self.data
def call(self, method, params=None):
"""Call the given method with the given parameters."""
return self._device._query_helper(self._module, method, params)
def query_for_command(self, query, params=None):
"""Create a request object for the given parameters."""
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}>"

View File

@@ -0,0 +1,83 @@
"""Base implementation for all rule-based modules."""
import logging
from enum import Enum
from typing import Dict, List, Optional
from pydantic import BaseModel
from .module import Module, merge
class Action(Enum):
"""Action to perform."""
Disabled = -1
TurnOff = 0
TurnOn = 1
Unknown = 2
class TimeOption(Enum):
"""Time when the action is executed."""
Disabled = -1
Enabled = 0
AtSunrise = 1
AtSunset = 2
class Rule(BaseModel):
"""Representation of a rule."""
id: str
name: str
enable: bool
wday: List[int]
repeat: bool
# start action
sact: Optional[Action]
stime_opt: TimeOption
smin: int
eact: Optional[Action]
etime_opt: TimeOption
emin: int
# Only on bulbs
s_light: Optional[Dict]
_LOGGER = logging.getLogger(__name__)
class RuleModule(Module):
"""Base class for rule-based modules, such as countdown and antitheft."""
def query(self):
"""Prepare the query for rules."""
q = self.query_for_command("get_rules")
return merge(q, self.query_for_command("get_next_action"))
@property
def rules(self) -> List[Rule]:
"""Return the list of rules for the service."""
try:
return [
Rule.parse_obj(rule) for rule in self.data["get_rules"]["rule_list"]
]
except Exception as ex:
_LOGGER.error("Unable to read rule list: %s (data: %s)", ex, self.data)
return []
async def set_enabled(self, state: bool):
"""Enable or disable the service."""
return await self.call("set_overall_enable", state)
async def delete_rule(self, rule: Rule):
"""Delete the given rule."""
return await self.call("delete_rule", {"id": rule.id})
async def delete_all_rules(self):
"""Delete all rules."""
return await self.call("delete_all_rules")

6
kasa/modules/schedule.py Normal file
View File

@@ -0,0 +1,6 @@
"""Schedule module implementation."""
from .rulemodule import RuleModule
class Schedule(RuleModule):
"""Implements the scheduling interface."""

34
kasa/modules/time.py Normal file
View File

@@ -0,0 +1,34 @@
"""Provides the current time and timezone information."""
from datetime import datetime
from .module import Module, merge
class Time(Module):
"""Implements the timezone settings."""
def query(self):
"""Request time and timezone."""
q = self.query_for_command("get_time")
merge(q, self.query_for_command("get_timezone"))
return q
@property
def time(self) -> datetime:
"""Return current device time."""
res = self.data["get_time"]
return datetime(
res["year"],
res["month"],
res["mday"],
res["hour"],
res["min"],
res["sec"],
)
@property
def timezone(self):
"""Return current timezone."""
res = self.data["get_timezone"]
return res

40
kasa/modules/usage.py Normal file
View File

@@ -0,0 +1,40 @@
"""Implementation of the usage interface."""
from datetime import datetime
from .module import Module, merge
class Usage(Module):
"""Baseclass for emeter/usage interfaces."""
def query(self):
"""Return the base query."""
year = datetime.now().year
month = datetime.now().month
req = self.query_for_command("get_realtime")
req = merge(
req, self.query_for_command("get_daystat", {"year": year, "month": month})
)
req = merge(req, self.query_for_command("get_monthstat", {"year": year}))
req = merge(req, self.query_for_command("get_next_action"))
return req
async def get_daystat(self, year, month):
"""Return stats for the current day."""
if year is None:
year = datetime.now().year
if month is None:
month = datetime.now().month
return await self.call("get_daystat", {"year": year, "month": month})
async def get_monthstat(self, year):
"""Return stats for the current month."""
if year is None:
year = datetime.now().year
return await self.call("get_monthstat", {"year": year})
async def erase_stats(self):
"""Erase all stats."""
return await self.call("erase_runtime_stat")