mirror of
https://github.com/python-kasa/python-kasa.git
synced 2025-08-08 03:34:02 +00:00
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:
10
kasa/modules/__init__.py
Normal file
10
kasa/modules/__init__.py
Normal 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
|
9
kasa/modules/antitheft.py
Normal file
9
kasa/modules/antitheft.py
Normal 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
50
kasa/modules/cloud.py
Normal 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")
|
6
kasa/modules/countdown.py
Normal file
6
kasa/modules/countdown.py
Normal 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
20
kasa/modules/emeter.py
Normal 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
59
kasa/modules/module.py
Normal 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}>"
|
83
kasa/modules/rulemodule.py
Normal file
83
kasa/modules/rulemodule.py
Normal 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
6
kasa/modules/schedule.py
Normal 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
34
kasa/modules/time.py
Normal 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
40
kasa/modules/usage.py
Normal 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")
|
Reference in New Issue
Block a user