Skip to content

Commit

Permalink
Initial implementation for modularized smartdevice (#757)
Browse files Browse the repository at this point in the history
The initial steps to modularize the smartdevice. Modules are initialized based on the component negotiation, and each module can indicate which features it supports and which queries should be run during the update cycle.
  • Loading branch information
rytilahti committed Feb 19, 2024
1 parent e86dcb6 commit 1171999
Show file tree
Hide file tree
Showing 21 changed files with 408 additions and 156 deletions.
5 changes: 1 addition & 4 deletions kasa/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,10 +590,7 @@ async def state(ctx, dev: Device):

echo("\n\t[bold]== Modules ==[/bold]")
for module in dev.modules.values():
if module.is_supported:
echo(f"\t[green]+ {module}[/green]")
else:
echo(f"\t[red]- {module}[/red]")
echo(f"\t[green]+ {module}[/green]")

if verbose:
echo("\n\t[bold]== Verbose information ==[/bold]")
Expand Down
3 changes: 2 additions & 1 deletion kasa/iot/iotdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
from ..exceptions import SmartDeviceException
from ..feature import Feature
from ..protocol import BaseProtocol
from .modules import Emeter, IotModule
from .iotmodule import IotModule
from .modules import Emeter

_LOGGER = logging.getLogger(__name__)

Expand Down
60 changes: 12 additions & 48 deletions kasa/iot/modules/module.py → kasa/iot/iotmodule.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
"""Base class for all module implementations."""
"""Base class for IOT module implementations."""
import collections
import logging
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Dict

from ...exceptions import SmartDeviceException
from ...feature import Feature

if TYPE_CHECKING:
from kasa.iot import IotDevice

from ..exceptions import SmartDeviceException
from ..module import Module

_LOGGER = logging.getLogger(__name__)


# TODO: This is used for query construcing
# TODO: This is used for query constructing, check for a better place
def merge(d, u):
"""Update dict recursively."""
for k, v in u.items():
Expand All @@ -25,32 +19,16 @@ def merge(d, u):
return d


class IotModule(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.
"""
class IotModule(Module):
"""Base class implemention for all IOT modules."""

def __init__(self, device: "IotDevice", module: str):
self._device = device
self._module = module
self._module_features: Dict[str, Feature] = {}

def _add_feature(self, feature: Feature):
"""Add module feature."""
feature_name = f"{self._module}_{feature.name}"
if feature_name in self._module_features:
raise SmartDeviceException("Duplicate name detected %s" % feature_name)
self._module_features[feature_name] = feature

@abstractmethod
def query(self):
"""Query to execute during the update cycle.
def call(self, method, params=None):
"""Call the given method with the given parameters."""
return self._device._query_helper(self._module, method, params)

The inheriting modules implement this to include their wanted
queries to the query that gets executed when Device.update() gets called.
"""
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)

@property
def estimated_query_response_size(self):
Expand Down Expand Up @@ -80,17 +58,3 @@ def is_supported(self) -> bool:
return True

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})"
f" for {self._device.host}>"
)
2 changes: 0 additions & 2 deletions kasa/iot/modules/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from .cloud import Cloud
from .countdown import Countdown
from .emeter import Emeter
from .module import IotModule
from .motion import Motion
from .rulemodule import Rule, RuleModule
from .schedule import Schedule
Expand All @@ -17,7 +16,6 @@
"Cloud",
"Countdown",
"Emeter",
"IotModule",
"Motion",
"Rule",
"RuleModule",
Expand Down
2 changes: 1 addition & 1 deletion kasa/iot/modules/ambientlight.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Implementation of the ambient light (LAS) module found in some dimmers."""
from .module import IotModule
from ..iotmodule import IotModule

# TODO create tests and use the config reply there
# [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450,
Expand Down
2 changes: 1 addition & 1 deletion kasa/iot/modules/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pydantic import BaseModel

from ...feature import Feature, FeatureType
from .module import IotModule
from ..iotmodule import IotModule


class CloudInfo(BaseModel):
Expand Down
2 changes: 1 addition & 1 deletion kasa/iot/modules/motion.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Optional

from ...exceptions import SmartDeviceException
from .module import IotModule
from ..iotmodule import IotModule


class Range(Enum):
Expand Down
2 changes: 1 addition & 1 deletion kasa/iot/modules/rulemodule.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pydantic import BaseModel


from .module import IotModule, merge
from ..iotmodule import IotModule, merge


class Action(Enum):
Expand Down
2 changes: 1 addition & 1 deletion kasa/iot/modules/time.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from datetime import datetime

from ...exceptions import SmartDeviceException
from .module import IotModule, merge
from ..iotmodule import IotModule, merge


class Time(IotModule):
Expand Down
2 changes: 1 addition & 1 deletion kasa/iot/modules/usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from datetime import datetime
from typing import Dict

from .module import IotModule, merge
from ..iotmodule import IotModule, merge


class Usage(IotModule):
Expand Down
49 changes: 49 additions & 0 deletions kasa/module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Base class for all module implementations."""
import logging
from abc import ABC, abstractmethod
from typing import Dict

from .device import Device
from .exceptions import SmartDeviceException
from .feature import Feature

_LOGGER = logging.getLogger(__name__)


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: "Device", module: str):
self._device = device
self._module = module
self._module_features: Dict[str, Feature] = {}

@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
@abstractmethod
def data(self):
"""Return the module specific raw data from the last update."""

def _add_feature(self, feature: Feature):
"""Add module feature."""
feat_name = f"{self._module}_{feature.name}"
if feat_name in self._module_features:
raise SmartDeviceException("Duplicate name detected %s" % feat_name)
self._module_features[feat_name] = feature

def __repr__(self) -> str:
return (
f"<Module {self.__class__.__name__} ({self._module})"
f" for {self._device.host}>"
)
7 changes: 7 additions & 0 deletions kasa/smart/modules/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Modules for SMART devices."""
from .childdevicemodule import ChildDeviceModule
from .devicemodule import DeviceModule
from .energymodule import EnergyModule
from .timemodule import TimeModule

__all__ = ["TimeModule", "EnergyModule", "DeviceModule", "ChildDeviceModule"]
9 changes: 9 additions & 0 deletions kasa/smart/modules/childdevicemodule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Implementation for child devices."""
from ..smartmodule import SmartModule


class ChildDeviceModule(SmartModule):
"""Implementation for child devices."""

REQUIRED_COMPONENT = "child_device"
QUERY_GETTER_NAME = "get_child_device_list"
21 changes: 21 additions & 0 deletions kasa/smart/modules/devicemodule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Implementation of device module."""
from typing import Dict

from ..smartmodule import SmartModule


class DeviceModule(SmartModule):
"""Implementation of device module."""

REQUIRED_COMPONENT = "device"

def query(self) -> Dict:
"""Query to execute during the update cycle."""
query = {
"get_device_info": None,
}
# Device usage is not available on older firmware versions
if self._device._components[self.REQUIRED_COMPONENT] >= 2:
query["get_device_usage"] = None

return query
88 changes: 88 additions & 0 deletions kasa/smart/modules/energymodule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Implementation of energy monitoring module."""
from typing import TYPE_CHECKING, Dict, Optional

from ...emeterstatus import EmeterStatus
from ...feature import Feature
from ..smartmodule import SmartModule

if TYPE_CHECKING:
from ..smartdevice import SmartDevice


class EnergyModule(SmartModule):
"""Implementation of energy monitoring module."""

REQUIRED_COMPONENT = "energy_monitoring"

def __init__(self, device: "SmartDevice", module: str):
super().__init__(device, module)
self._add_feature(
Feature(
device,
name="Current consumption",
attribute_getter="current_power",
container=self,
)
) # W or mW?
self._add_feature(
Feature(
device,
name="Today's consumption",
attribute_getter="emeter_today",
container=self,
)
) # Wh or kWh?
self._add_feature(
Feature(
device,
name="This month's consumption",
attribute_getter="emeter_this_month",
container=self,
)
) # Wh or kWH?

def query(self) -> Dict:
"""Query to execute during the update cycle."""
return {
"get_energy_usage": None,
# The current_power in get_energy_usage is more precise (mw vs. w),
# making this rather useless, but maybe there are version differences?
"get_current_power": None,
}

@property
def current_power(self):
"""Current power."""
return self.emeter_realtime.power

@property
def energy(self):
"""Return get_energy_usage results."""
return self.data["get_energy_usage"]

@property
def emeter_realtime(self):
"""Get the emeter status."""
# TODO: Perhaps we should get rid of emeterstatus altogether for smartdevices
return EmeterStatus(
{
"power_mw": self.energy.get("current_power"),
"total": self._convert_energy_data(
self.energy.get("today_energy"), 1 / 1000
),
}
)

@property
def emeter_this_month(self) -> Optional[float]:
"""Get the emeter value for this month."""
return self._convert_energy_data(self.energy.get("month_energy"), 1 / 1000)

@property
def emeter_today(self) -> Optional[float]:
"""Get the emeter value for today."""
return self._convert_energy_data(self.energy.get("today_energy"), 1 / 1000)

def _convert_energy_data(self, data, scale) -> Optional[float]:
"""Return adjusted emeter information."""
return data if not data else data * scale

0 comments on commit 1171999

Please sign in to comment.