generalize smartdevice class and add bulb support for the cli tool (#50)

Fixes #48 and #51. The basic functionality should work on all types of supported devices, for bulb specific commands it is currently necessary to specify ```--bulb```.
This commit is contained in:
Teemu R 2017-04-24 19:28:22 +02:00 committed by GitHub
parent 86f14642c8
commit 09e8948790
5 changed files with 204 additions and 92 deletions

View File

@ -2,4 +2,4 @@
from .smartplug import SmartPlug from .smartplug import SmartPlug
from .pyHS100 import SmartPlugException, SmartDevice from .pyHS100 import SmartPlugException, SmartDevice
from .smartbulb import SmartBulb from .smartbulb import SmartBulb
from .protocol import TPLinkSmartHomeProtocol from .protocol import TPLinkSmartHomeProtocol

View File

@ -9,16 +9,18 @@ if sys.version_info < (3, 4):
sys.version_info) sys.version_info)
sys.exit(1) sys.exit(1)
from pyHS100 import SmartPlug, TPLinkSmartHomeProtocol # noqa: E402 from pyHS100 import (SmartDevice, SmartPlug, SmartBulb,
TPLinkSmartHomeProtocol) # noqa: E402
pass_dev = click.make_pass_decorator(SmartPlug) pass_dev = click.make_pass_decorator(SmartDevice)
@click.group(invoke_without_command=True) @click.group(invoke_without_command=True)
@click.option('--ip', envvar="PYHS100_IP", required=False) @click.option('--ip', envvar="PYHS100_IP", required=False)
@click.option('--debug/--normal', default=False) @click.option('--debug/--normal', default=False)
@click.option('--bulb', default=False, is_flag=True)
@click.pass_context @click.pass_context
def cli(ctx, ip, debug): def cli(ctx, ip, debug, bulb):
"""A cli tool for controlling TP-Link smart home plugs.""" """A cli tool for controlling TP-Link smart home plugs."""
if debug: if debug:
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
@ -32,8 +34,11 @@ def cli(ctx, ip, debug):
click.echo("You must specify the IP!") click.echo("You must specify the IP!")
sys.exit(-1) sys.exit(-1)
plug = SmartPlug(ip) if bulb:
ctx.obj = plug dev = SmartBulb(ip)
else:
dev = SmartPlug(ip)
ctx.obj = dev
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
ctx.invoke(state) ctx.invoke(state)
@ -50,29 +55,31 @@ def discover(timeout):
@cli.command() @cli.command()
@pass_dev @pass_dev
def sysinfo(plug): def sysinfo(dev):
"""Print out full system information.""" """Print out full system information."""
click.echo(click.style("== System info ==", bold=True)) click.echo(click.style("== System info ==", bold=True))
click.echo(pformat(plug.sys_info)) click.echo(pformat(dev.sys_info))
@cli.command() @cli.command()
@pass_dev @pass_dev
@click.pass_context @click.pass_context
def state(ctx, plug): def state(ctx, dev):
"""Print out device state and versions.""" """Print out device state and versions."""
click.echo(click.style("== %s - %s ==" % (plug.alias, plug.model), click.echo(click.style("== %s - %s ==" % (dev.alias, dev.model),
bold=True)) bold=True))
click.echo(click.style("Device state: %s" % plug.state, click.echo(click.style("Device state: %s" % "ON" if dev.is_on else "OFF",
fg="green" if plug.is_on else "red")) fg="green" if dev.is_on else "red"))
click.echo("LED state: %s" % plug.led) for k, v in dev.state_information.items():
click.echo("Time: %s" % plug.time) click.echo("%s: %s" % (k, v))
click.echo("On since: %s" % plug.on_since) click.echo(click.style("== Generic information ==", bold=True))
click.echo("Hardware: %s" % plug.hw_info["hw_ver"]) click.echo("Time: %s" % dev.time)
click.echo("Software: %s" % plug.hw_info["sw_ver"]) click.echo("Hardware: %s" % dev.hw_info["hw_ver"])
click.echo("MAC (rssi): %s (%s)" % (plug.mac, plug.rssi)) click.echo("Software: %s" % dev.hw_info["sw_ver"])
click.echo("Location: %s" % plug.location) click.echo("MAC (rssi): %s (%s)" % (dev.mac, dev.rssi))
click.echo("Location: %s" % dev.location)
ctx.invoke(emeter) ctx.invoke(emeter)
@ -83,37 +90,74 @@ def state(ctx, plug):
@click.option('--month', type=Datetime(format='%Y-%m'), @click.option('--month', type=Datetime(format='%Y-%m'),
default=None, required=False) default=None, required=False)
@click.option('--erase', is_flag=True) @click.option('--erase', is_flag=True)
def emeter(plug, year, month, erase): def emeter(dev, year, month, erase):
"""Query emeter for historical consumption.""" """Query emeter for historical consumption."""
click.echo(click.style("== Emeter ==", bold=True)) click.echo(click.style("== Emeter ==", bold=True))
if not plug.has_emeter: if not dev.has_emeter:
click.echo("Device has no emeter") click.echo("Device has no emeter")
return return
if erase: if erase:
click.echo("Erasing emeter statistics..") click.echo("Erasing emeter statistics..")
plug.erase_emeter_stats() dev.erase_emeter_stats()
return return
click.echo("Current state: %s" % plug.get_emeter_realtime()) click.echo("Current state: %s" % dev.get_emeter_realtime())
if year: if year:
click.echo("== For year %s ==" % year.year) click.echo("== For year %s ==" % year.year)
click.echo(plug.get_emeter_monthly(year.year)) click.echo(dev.get_emeter_monthly(year.year))
elif month: elif month:
click.echo("== For month %s of %s ==" % (month.month, month.year)) click.echo("== For month %s of %s ==" % (month.month, month.year))
plug.get_emeter_daily(year=month.year, month=month.month) dev.get_emeter_daily(year=month.year, month=month.month)
@cli.command()
@click.argument("brightness", type=click.IntRange(0, 100), default=None)
@pass_dev
def brightness(dev, value):
"""Get or set brightness. (Bulb Only)"""
if value is None:
click.echo("Brightness: %s" % dev.brightness)
else:
click.echo("Setting brightness to %s" % value)
dev.brightness = value
@cli.command()
@click.argument("temperature", type=click.IntRange(2700, 6500), default=None)
@pass_dev
def temperature(dev, value):
"""Get or set color temperature. (Bulb only)"""
if value is None:
click.echo("Color temperature: %s" % dev.color_temp)
else:
click.echo("Setting color temperature to %s" % value)
dev.color_temp = value
@cli.command()
@click.argument("h", type=click.IntRange(0, 255), default=None)
@click.argument("s", type=click.IntRange(0, 255), default=None)
@click.argument("v", type=click.IntRange(0, 255), default=None)
def hsv(dev, h, s, v):
"""Get or set color in HSV. (Bulb only)"""
if h is None or s is None or v is None:
click.echo("Current HSV: %s" % dev.hsv)
else:
click.echo("Setting HSV: %s %s %s" % (h, s, v))
dev.hsv = (h, s, v)
@cli.command() @cli.command()
@click.argument('state', type=bool, required=False) @click.argument('state', type=bool, required=False)
@pass_dev @pass_dev
def led(plug, state): def led(dev, state):
"""Get or set led state.""" """Get or set led state. (Plug only)"""
if state is not None: if state is not None:
click.echo("Turning led to %s" % state) click.echo("Turning led to %s" % state)
plug.led = state dev.led = state
else: else:
click.echo("LED state: %s" % plug.led) click.echo("LED state: %s" % dev.led)
@cli.command() @cli.command()

View File

@ -16,6 +16,7 @@ http://www.apache.org/licenses/LICENSE-2.0
import datetime import datetime
import logging import logging
import socket import socket
import enum
from .protocol import TPLinkSmartHomeProtocol from .protocol import TPLinkSmartHomeProtocol
@ -29,7 +30,20 @@ class SmartPlugException(Exception):
pass pass
class DeviceType(enum.Enum):
Unknown = -1,
Plug = 0,
Switch = 1
Bulb = 2
class SmartDevice(object): class SmartDevice(object):
# possible device features
FEATURE_ENERGY_METER = 'ENE'
FEATURE_TIMER = 'TIM'
ALL_FEATURES = (FEATURE_ENERGY_METER, FEATURE_TIMER)
def __init__(self, ip_address, protocol=None): def __init__(self, ip_address, protocol=None):
""" """
Create a new SmartDevice instance, identified through its IP address. Create a new SmartDevice instance, identified through its IP address.
@ -78,32 +92,43 @@ class SmartDevice(object):
return result return result
@property
def features(self):
"""
Returns features of the devices
:return: list of features
:rtype: list
"""
features = self.sys_info['feature'].split(':')
for feature in features:
if feature not in SmartDevice.ALL_FEATURES:
_LOGGER.warning("Unknown feature %s on device %s.",
feature, self.model)
return features
@property
def has_emeter(self):
"""
Checks feature list for energey meter support.
:return: True if energey meter is available
False if energymeter is missing
"""
return SmartDevice.FEATURE_ENERGY_METER in self.features
@property @property
def sys_info(self): def sys_info(self):
# TODO use volyptuous """
Returns the complete system information from the device.
:return: System information dict.
:rtype: dict
"""
return self.get_sysinfo() return self.get_sysinfo()
@property
def is_off(self):
"""
Returns whether device is off.
:return: True if device is off, False otherwise.
:rtype: bool
"""
return not self.is_on
@property
def is_on(self):
"""
Returns whether the device is on.
:return: True if the device is on, False otherwise.
:rtype: bool
:return:
"""
raise NotImplementedError("Your subclass needs to implement this.")
def get_sysinfo(self): def get_sysinfo(self):
""" """
Retrieve system information. Retrieve system information.
@ -402,3 +427,45 @@ class SmartDevice(object):
return response['power_mw'] return response['power_mw']
else: else:
return response['power'] return response['power']
def turn_off(self):
"""
Turns the device off.
"""
raise NotImplementedError("Device subclass needs to implement this.")
@property
def is_off(self):
"""
Returns whether device is off.
:return: True if device is off, False otherwise.
:rtype: bool
"""
return not self.is_on
def turn_on(self):
"""
Turns the device on.
"""
raise NotImplementedError("Device subclass needs to implement this.")
@property
def is_on(self):
"""
Returns whether the device is on.
:return: True if the device is on, False otherwise.
:rtype: bool
:return:
"""
raise NotImplementedError("Device subclass needs to implement this.")
@property
def state_information(self):
"""
Returns device-type specific, end-user friendly state information.
:return: dict with state information.
:rtype: dict
"""
raise NotImplementedError("Device subclass needs to implement this.")

View File

@ -106,7 +106,7 @@ class SmartBulb(SmartDevice):
saturation = light_state['saturation'] saturation = light_state['saturation']
value = int(light_state['brightness'] * 255 / 100) value = int(light_state['brightness'] * 255 / 100)
return(hue, saturation, value) return hue, saturation, value
@hsv.setter @hsv.setter
def hsv(self, state): def hsv(self, state):
@ -139,9 +139,9 @@ class SmartBulb(SmartDevice):
light_state = self.get_light_state() light_state = self.get_light_state()
if not self.is_on: if not self.is_on:
return(light_state['dft_on_state']['color_temp']) return light_state['dft_on_state']['color_temp']
else: else:
return(light_state['color_temp']) return light_state['color_temp']
@color_temp.setter @color_temp.setter
def color_temp(self, temp): def color_temp(self, temp):
@ -171,9 +171,9 @@ class SmartBulb(SmartDevice):
light_state = self.get_light_state() light_state = self.get_light_state()
if not self.is_on: if not self.is_on:
return(light_state['dft_on_state']['brightness']) return light_state['dft_on_state']['brightness']
else: else:
return(light_state['brightness']) return light_state['brightness']
@brightness.setter @brightness.setter
def brightness(self, brightness): def brightness(self, brightness):
@ -205,10 +205,40 @@ class SmartBulb(SmartDevice):
return self.BULB_STATE_ON return self.BULB_STATE_ON
return self.BULB_STATE_OFF return self.BULB_STATE_OFF
@property
def state_information(self):
"""
Return bulb-specific state information.
:return: Bulb information dict, keys in user-presentable form.
:rtype: dict
"""
info = {
'Brightness': self.brightness,
'Is dimmable': self.is_dimmable,
}
if self.is_variable_color_temp:
info["Color temperature"] = self.color_temp
if self.is_color:
info["HSV"] = self.hsv
return info
@property @property
def is_on(self): def is_on(self):
return self.state == self.BULB_STATE_ON return self.state == self.BULB_STATE_ON
def turn_off(self):
"""
Turn the bulb off.
"""
self.state = self.BULB_STATE_OFF
def turn_on(self):
"""
Turn the bulb on.
"""
self.state = self.BULB_STATE_ON
@state.setter @state.setter
def state(self, bulb_state): def state(self, bulb_state):
""" """
@ -218,9 +248,6 @@ class SmartBulb(SmartDevice):
BULB_STATE_ON BULB_STATE_ON
BULB_STATE_OFF BULB_STATE_OFF
""" """
print(bulb_state)
print(self.BULB_STATE_ON)
print(self.BULB_STATE_OFF)
if bulb_state == self.BULB_STATE_ON: if bulb_state == self.BULB_STATE_ON:
bulb_state = 1 bulb_state = 1
elif bulb_state == self.BULB_STATE_OFF: elif bulb_state == self.BULB_STATE_OFF:

View File

@ -30,12 +30,6 @@ class SmartPlug(SmartDevice):
SWITCH_STATE_OFF = 'OFF' SWITCH_STATE_OFF = 'OFF'
SWITCH_STATE_UNKNOWN = 'UNKNOWN' SWITCH_STATE_UNKNOWN = 'UNKNOWN'
# possible device features
FEATURE_ENERGY_METER = 'ENE'
FEATURE_TIMER = 'TIM'
ALL_FEATURES = (FEATURE_ENERGY_METER, FEATURE_TIMER)
def __init__(self, ip_address, protocol=None): def __init__(self, ip_address, protocol=None):
SmartDevice.__init__(self, ip_address, protocol) SmartDevice.__init__(self, ip_address, protocol)
self.emeter_type = "emeter" self.emeter_type = "emeter"
@ -108,33 +102,6 @@ class SmartPlug(SmartDevice):
""" """
self._query_helper("system", "set_relay_state", {"state": 0}) self._query_helper("system", "set_relay_state", {"state": 0})
@property
def has_emeter(self):
"""
Checks feature list for energey meter support.
:return: True if energey meter is available
False if energymeter is missing
"""
return SmartPlug.FEATURE_ENERGY_METER in self.features
@property
def features(self):
"""
Returns features of the devices
:return: list of features
:rtype: list
"""
features = self.sys_info['feature'].split(':')
for feature in features:
if feature not in SmartPlug.ALL_FEATURES:
_LOGGER.warning("Unknown feature %s on device %s.",
feature, self.model)
return features
@property @property
def led(self): def led(self):
""" """
@ -145,6 +112,13 @@ class SmartPlug(SmartDevice):
""" """
return bool(1 - self.sys_info["led_off"]) return bool(1 - self.sys_info["led_off"])
@property
def state_information(self):
return {
'LED state': self.led,
'On since': self.on_since
}
@led.setter @led.setter
def led(self, state): def led(self, state):
""" """