Skip to content
Merged
68 changes: 65 additions & 3 deletions kasa/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ async def state(ctx, dev: SmartDevice):
click.echo()

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

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


@cli.command()
@pass_dev
Expand Down Expand Up @@ -309,7 +316,6 @@ async def emeter(dev: SmartDevice, year, month, erase):
usage_data = await dev.get_emeter_daily(year=month.year, month=month.month)
else:
# Call with no argument outputs summary data and returns
usage_data = {}
emeter_status = dev.emeter_realtime

click.echo("Current: %s A" % emeter_status["current"])
Expand All @@ -327,6 +333,44 @@ async def emeter(dev: SmartDevice, year, month, erase):
click.echo(f"{index}, {usage}")


@cli.command()
@pass_dev
@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False)
@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False)
@click.option("--erase", is_flag=True)
async def usage(dev: SmartDevice, year, month, erase):
"""Query usage for historical consumption.

Daily and monthly data provided in CSV format.
"""
click.echo(click.style("== Usage ==", bold=True))
usage = dev.modules["usage"]

if erase:
click.echo("Erasing usage statistics..")
click.echo(await usage.erase_stats())
return

if year:
click.echo(f"== For year {year.year} ==")
click.echo("Month, usage (minutes)")
usage_data = await usage.get_monthstat(year.year)
elif month:
click.echo(f"== For month {month.month} of {month.year} ==")
click.echo("Day, usage (minutes)")
usage_data = await usage.get_daystat(year=month.year, month=month.month)
else:
# Call with no argument outputs summary data and returns
click.echo("Today: %s minutes" % usage.usage_today)
click.echo("This month: %s minutes" % usage.usage_this_month)

return

# output any detailed usage data
for index, usage in usage_data.items():
click.echo(f"{index}, {usage}")


@cli.command()
@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False)
@click.option("--transition", type=int, required=False)
Expand Down Expand Up @@ -430,7 +474,7 @@ async def led(dev, state):
@pass_dev
async def time(dev):
"""Get the device time."""
res = await dev.get_time()
res = dev.time
click.echo(f"Current time: {res}")
return res

Expand Down Expand Up @@ -488,5 +532,23 @@ async def reboot(plug, delay):
return await plug.reboot(delay)


@cli.group()
@pass_dev
async def schedule(dev):
"""Scheduling commands."""


@schedule.command(name="list")
@pass_dev
@click.argument("type", default="schedule")
def _schedule_list(dev, type):
"""Return the list of schedule actions for the given type."""
sched = dev.modules[type]
for rule in sched.rules:
print(rule)
else:
click.echo(f"No rules of type {type}")


if __name__ == "__main__":
cli()
12 changes: 12 additions & 0 deletions kasa/modules/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# flake8: noqa
from .ambientlight import AmbientLight
from .antitheft import Antitheft
from .cloud import Cloud
from .countdown import Countdown
from .emeter import Emeter
from .module import Module
from .motion import Motion
from .rulemodule import Rule, RuleModule
from .schedule import Schedule
from .time import Time
from .usage import Usage
47 changes: 47 additions & 0 deletions kasa/modules/ambientlight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Implementation of the ambient light (LAS) module found in some dimmers."""
from .module import Module

# TODO create tests and use the config reply there
# [{"hw_id":0,"enable":0,"dark_index":1,"min_adc":0,"max_adc":2450,
# "level_array":[{"name":"cloudy","adc":490,"value":20},
# {"name":"overcast","adc":294,"value":12},
# {"name":"dawn","adc":222,"value":9},
# {"name":"twilight","adc":222,"value":9},
# {"name":"total darkness","adc":111,"value":4},
# {"name":"custom","adc":2400,"value":97}]}]


class AmbientLight(Module):
"""Implements ambient light controls for the motion sensor."""

def query(self):
"""Request configuration."""
return self.query_for_command("get_config")

@property
def presets(self) -> dict:
"""Return device-defined presets for brightness setting."""
return self.data["level_array"]

@property
def enabled(self) -> bool:
"""Return True if the module is enabled."""
return bool(self.data["enable"])

async def set_enabled(self, state: bool):
"""Enable/disable LAS."""
return await self.call("set_enable", {"enable": int(state)})

async def current_brightness(self) -> int:
"""Return current brightness.

Return value units.
"""
return await self.call("get_current_brt")

async def set_brightness_limit(self, value: int):
"""Set the limit when the motion sensor is inactive.

See `presets` for preset values. Custom values are also likely allowed.
"""
return await self.call("set_brt_level", {"index": 0, "value": value})
9 changes: 9 additions & 0 deletions kasa/modules/antitheft.py
Original file line number Diff line number Diff line change
@@ -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 changes: 50 additions & 0 deletions kasa/modules/cloud.py
Original file line number Diff line number Diff line change
@@ -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 changes: 6 additions & 0 deletions kasa/modules/countdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Implementation for the countdown timer."""
from .rulemodule import RuleModule


class Countdown(RuleModule):
"""Implementation of countdown module."""
73 changes: 73 additions & 0 deletions kasa/modules/emeter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Implementation of the emeter module."""
from datetime import datetime
from typing import Dict, Optional

from ..emeterstatus import EmeterStatus
from .usage import Usage


class Emeter(Usage):
"""Emeter module."""

@property # type: ignore
def realtime(self) -> EmeterStatus:
"""Return current energy readings."""
return EmeterStatus(self.data["get_realtime"])

@property
def emeter_today(self) -> Optional[float]:
"""Return today's energy consumption in kWh."""
raw_data = self.daily_data
today = datetime.now().day
data = self._emeter_convert_emeter_data(raw_data)

return data.get(today)

@property
def emeter_this_month(self) -> Optional[float]:
"""Return this month's energy consumption in kWh."""
raw_data = self.monthly_data
current_month = datetime.now().month
data = self._emeter_convert_emeter_data(raw_data)

return data.get(current_month)

async def erase_stats(self):
"""Erase all stats.

Uses different query than usage meter.
"""
return await self.call("erase_emeter_stat")

async def get_realtime(self):
"""Return real-time statistics."""
return await self.call("get_realtime")

async def get_daystat(self, *, year, month, kwh=True):
"""Return daily stats for the given year & month."""
raw_data = await super().get_daystat(year=year, month=month)
return self._emeter_convert_emeter_data(raw_data["day_list"], kwh)

async def get_monthstat(self, *, year, kwh=True):
"""Return monthly stats for the given year."""
raw_data = await super().get_monthstat(year=year)
return self._emeter_convert_emeter_data(raw_data["month_list"], kwh)

def _emeter_convert_emeter_data(self, data, kwh=True) -> Dict:
"""Return emeter information keyed with the day/month.."""
response = [EmeterStatus(**x) for x in data]

if not response:
return {}

energy_key = "energy_wh"
if kwh:
energy_key = "energy"

entry_key = "month"
if "day" in response[0]:
entry_key = "day"

data = {entry[entry_key]: entry[energy_key] for entry in response}

return data
74 changes: 74 additions & 0 deletions kasa/modules/module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Base class for all module implementations."""
import collections
import logging
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING

from ..exceptions import SmartDeviceException

if TYPE_CHECKING:
from kasa import SmartDevice


_LOGGER = logging.getLogger(__name__)


# 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."""
if self._module not in self._device._last_update:
raise SmartDeviceException(
f"You need to call update() prior accessing module data for '{self._module}'"
)

return self._device._last_update[self._module]

@property
def is_supported(self) -> bool:
"""Return whether the module is supported by the device."""
if self._module not in self._device._last_update:
_LOGGER.debug("Initial update, so consider supported: %s", self._module)
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}) for {self._device.host}>"
Loading