Discover refactoring, enhancements to the cli tool (#71)

* Discover refactoring, enhancements to the cli tool

* Discover tries to detect the type of the device from sysinfo response
* Discover.discover() returns an IP address keyed dictionary,
  values are initialized instances of the automatically detected device type.

* When no IP is given, autodetect all supported devices and print out their states
* When only IP but no type is given, autodetect type and make a call based on that information.
  * One can define --bulb or --plug to skip the detection.

* renamed pyHS100.py -> smartdevice.py

* SmartPlugException -> SmartDeviceException in comments

* fix mic_type check

* make time() return None on failure as we don't know which devices support getting the time and it's used in the cli tool

* hw_info: check if key exists before accessing it, add mic_mac and mic_type

* Check for mic_mac on mac, based on work by kdschloesser on issue #59

* make hound happy, __init__ on SmartDevice cannot error out so removing 'raises' documentation
This commit is contained in:
Teemu R 2017-08-05 15:49:56 +02:00 committed by GitHub
parent d7aade4e61
commit 07af48e41a
8 changed files with 154 additions and 92 deletions

View File

@ -13,8 +13,9 @@ Module-specific errors are raised as `SmartDeviceException` and are expected
to be handled by the user of the library. to be handled by the user of the library.
""" """
# flake8: noqa # flake8: noqa
from .smartdevice import SmartDevice
from .smartplug import SmartPlug from .smartplug import SmartPlug
from .pyHS100 import SmartDevice
from .types import SmartDeviceException from .types import SmartDeviceException
from .smartbulb import SmartBulb from .smartbulb import SmartBulb
from .protocol import TPLinkSmartHomeProtocol from .protocol import TPLinkSmartHomeProtocol
from .discover import Discover

View File

@ -2,7 +2,7 @@ import sys
import click import click
import logging import logging
from click_datetime import Datetime from click_datetime import Datetime
from pprint import pformat from pprint import pformat as pf
if sys.version_info < (3, 4): if sys.version_info < (3, 4):
print("To use this script you need python 3.4 or newer! got %s" % print("To use this script you need python 3.4 or newer! got %s" %
@ -10,7 +10,7 @@ if sys.version_info < (3, 4):
sys.exit(1) sys.exit(1)
from pyHS100 import (SmartDevice, SmartPlug, SmartBulb, from pyHS100 import (SmartDevice, SmartPlug, SmartBulb,
TPLinkSmartHomeProtocol) # noqa: E402 TPLinkSmartHomeProtocol, Discover) # noqa: E402
pass_dev = click.make_pass_decorator(SmartDevice) pass_dev = click.make_pass_decorator(SmartDevice)
@ -19,8 +19,9 @@ pass_dev = click.make_pass_decorator(SmartDevice)
@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.option('--bulb', default=False, is_flag=True)
@click.option('--plug', default=False, is_flag=True)
@click.pass_context @click.pass_context
def cli(ctx, ip, debug, bulb): def cli(ctx, ip, debug, bulb, plug):
"""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)
@ -31,13 +32,25 @@ def cli(ctx, ip, debug, bulb):
return return
if ip is None: if ip is None:
click.echo("You must specify the IP!") click.echo("No IP given, trying discovery..")
sys.exit(-1) ctx.invoke(discover)
return
if bulb: elif ip is not None:
if not bulb and not plug:
click.echo("No --bulb nor --plug given, discovering..")
devs = ctx.invoke(discover, discover_only=True)
for discovered_ip, discovered_dev in devs:
if discovered_ip == ip:
dev = discovered_dev
break
elif bulb:
dev = SmartBulb(ip) dev = SmartBulb(ip)
else: elif plug:
dev = SmartPlug(ip) dev = SmartPlug(ip)
else:
click.echo("Unable to detect type, use --bulb or --plug!")
return
ctx.obj = dev ctx.obj = dev
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
@ -45,12 +58,20 @@ def cli(ctx, ip, debug, bulb):
@cli.command() @cli.command()
@click.option('--timeout', default=5, required=False) @click.option('--timeout', default=3, required=False)
def discover(timeout): @click.option('--discover-only', default=False)
@click.pass_context
def discover(ctx, timeout, discover_only):
"""Discover devices in the network.""" """Discover devices in the network."""
click.echo("Discovering devices for %s seconds" % timeout) click.echo("Discovering devices for %s seconds" % timeout)
for dev in TPLinkSmartHomeProtocol.discover(timeout=timeout): found_devs = Discover.discover(timeout=timeout).items()
print("Found device: %s" % pformat(dev)) if not discover_only:
for ip, dev in found_devs:
ctx.obj = dev
ctx.invoke(state)
print()
return found_devs
@cli.command() @cli.command()
@ -58,7 +79,7 @@ def discover(timeout):
def sysinfo(dev): 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(dev.sys_info)) click.echo(pf(dev.sys_info))
@cli.command() @cli.command()
@ -71,6 +92,7 @@ def state(ctx, dev):
click.echo(click.style("Device state: %s" % "ON" if dev.is_on else "OFF", click.echo(click.style("Device state: %s" % "ON" if dev.is_on else "OFF",
fg="green" if dev.is_on else "red")) fg="green" if dev.is_on else "red"))
click.echo("IP address: %s" % dev.ip_address)
for k, v in dev.state_information.items(): for k, v in dev.state_information.items():
click.echo("%s: %s" % (k, v)) click.echo("%s: %s" % (k, v))
click.echo(click.style("== Generic information ==", bold=True)) click.echo(click.style("== Generic information ==", bold=True))

68
pyHS100/discover.py Normal file
View File

@ -0,0 +1,68 @@
import socket
import logging
import json
from pyHS100 import TPLinkSmartHomeProtocol, SmartPlug, SmartBulb
_LOGGER = logging.getLogger(__name__)
class Discover:
@staticmethod
def discover(protocol=None, port=9999, timeout=3):
"""
Sends discovery message to 255.255.255.255:9999 in order
to detect available supported devices in the local network,
and waits for given timeout for answers from devices.
:param timeout: How long to wait for responses, defaults to 5
:param port: port to send broadcast messages, defaults to 9999.
:rtype: dict
:return: Array of json objects {"ip", "port", "sys_info"}
"""
if protocol is None:
protocol = TPLinkSmartHomeProtocol()
discovery_query = {"system": {"get_sysinfo": None},
"emeter": {"get_realtime": None}}
target = "255.255.255.255"
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(timeout)
req = json.dumps(discovery_query)
_LOGGER.debug("Sending discovery to %s:%s", target, port)
encrypted_req = protocol.encrypt(req)
sock.sendto(encrypted_req[4:], (target, port))
devices = {}
_LOGGER.debug("Waiting %s seconds for responses...", timeout)
try:
while True:
data, addr = sock.recvfrom(4096)
ip, port = addr
info = json.loads(protocol.decrypt(data))
if "system" in info and "get_sysinfo" in info["system"]:
sysinfo = info["system"]["get_sysinfo"]
if "type" in sysinfo:
type = sysinfo["type"]
elif "mic_type" in sysinfo:
type = sysinfo["mic_type"]
else:
_LOGGER.error("Unable to find the device type field!")
type = "UNKNOWN"
else:
_LOGGER.error("No 'system' nor 'get_sysinfo' in response")
if "smartplug" in type.lower():
devices[ip] = SmartPlug(ip)
elif "smartbulb" in type.lower():
devices[ip] = SmartBulb(ip)
except socket.timeout:
_LOGGER.debug("Got socket timeout, which is okay.")
except Exception as ex:
_LOGGER.error("Got exception %s", ex, exc_info=True)
return devices

View File

@ -39,7 +39,7 @@ class TPLinkSmartHomeProtocol:
request = json.dumps(request) request = json.dumps(request)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5.0) sock.settimeout(TPLinkSmartHomeProtocol.DEFAULT_TIMEOUT)
try: try:
sock.connect((host, port)) sock.connect((host, port))
@ -74,50 +74,6 @@ class TPLinkSmartHomeProtocol:
return json.loads(response) return json.loads(response)
@staticmethod
def discover(timeout=DEFAULT_TIMEOUT, port=DEFAULT_PORT):
"""
Sends discovery message to 255.255.255.255:9999 in order
to detect available supported devices in the local network,
and waits for given timeout for answers from devices.
:param timeout: How long to wait for responses, defaults to 5
:param port: port to send broadcast messages, defaults to 9999.
:rtype: list[dict]
:return: Array of json objects {"ip", "port", "sys_info"}
"""
discovery_query = {"system": {"get_sysinfo": None},
"emeter": {"get_realtime": None}}
target = "255.255.255.255"
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.settimeout(timeout)
req = json.dumps(discovery_query)
_LOGGER.debug("Sending discovery to %s:%s", target, port)
encrypted_req = TPLinkSmartHomeProtocol.encrypt(req)
sock.sendto(encrypted_req[4:], (target, port))
devices = []
_LOGGER.debug("Waiting %s seconds for responses...", timeout)
try:
while True:
data, addr = sock.recvfrom(4096)
ip, port = addr
info = json.loads(TPLinkSmartHomeProtocol.decrypt(data))
devices.append({"ip": ip, "port": port, "sys_info": info})
except socket.timeout:
_LOGGER.debug("Got socket timeout, which is okay.")
except Exception as ex:
_LOGGER.error("Got exception %s", ex, exc_info=True)
return devices
@staticmethod @staticmethod
def encrypt(request): def encrypt(request):
""" """

View File

@ -1,4 +1,4 @@
from .pyHS100 import SmartDevice from pyHS100 import SmartDevice
class SmartBulb(SmartDevice): class SmartBulb(SmartDevice):
@ -32,7 +32,7 @@ class SmartBulb(SmartDevice):
# check the current brightness # check the current brightness
print(p.brightness) print(p.brightness)
Errors reported by the device are raised as SmartPlugExceptions, Errors reported by the device are raised as SmartDeviceExceptions,
and should be handled by the user of the library. and should be handled by the user of the library.
""" """

View File

@ -35,7 +35,6 @@ class SmartDevice(object):
Create a new SmartDevice instance, identified through its IP address. Create a new SmartDevice instance, identified through its IP address.
:param str ip_address: ip address on which the device listens :param str ip_address: ip address on which the device listens
:raises SmartPlugException: when unable to communicate with the device
""" """
socket.inet_pton(socket.AF_INET, ip_address) socket.inet_pton(socket.AF_INET, ip_address)
self.ip_address = ip_address self.ip_address = ip_address
@ -52,7 +51,7 @@ class SmartDevice(object):
:param arg: JSON object passed as parameter to the command :param arg: JSON object passed as parameter to the command
:return: Unwrapped result for the call. :return: Unwrapped result for the call.
:rtype: dict :rtype: dict
:raises SmartPlugException: if command was not executed correctly :raises SmartDeviceException: if command was not executed correctly
""" """
if arg is None: if arg is None:
arg = {} arg = {}
@ -121,7 +120,7 @@ class SmartDevice(object):
:return: sysinfo :return: sysinfo
:rtype dict :rtype dict
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
return self._query_helper("system", "get_sysinfo") return self._query_helper("system", "get_sysinfo")
@ -146,7 +145,7 @@ class SmartDevice(object):
:return: device model :return: device model
:rtype: str :rtype: str
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
return self.sys_info['model'] return self.sys_info['model']
@ -166,7 +165,7 @@ class SmartDevice(object):
Sets the device name aka alias. Sets the device name aka alias.
:param alias: New alias (name) :param alias: New alias (name)
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
self._query_helper("system", "set_dev_alias", {"alias": alias}) self._query_helper("system", "set_dev_alias", {"alias": alias})
@ -179,7 +178,7 @@ class SmartDevice(object):
:return: icon and its hash :return: icon and its hash
:rtype: dict :rtype: dict
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
return self._query_helper("system", "get_dev_icon") return self._query_helper("system", "get_dev_icon")
@ -204,12 +203,15 @@ class SmartDevice(object):
Returns current time from the device. Returns current time from the device.
:return: datetime for device's time :return: datetime for device's time
:rtype: datetime.datetime :rtype: datetime.datetime or None when not available
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
try:
res = self._query_helper("time", "get_time") res = self._query_helper("time", "get_time")
return datetime.datetime(res["year"], res["month"], res["mday"], return datetime.datetime(res["year"], res["month"], res["mday"],
res["hour"], res["min"], res["sec"]) res["hour"], res["min"], res["sec"])
except SmartDeviceException:
return None
@time.setter @time.setter
def time(self, ts): def time(self, ts):
@ -221,7 +223,7 @@ class SmartDevice(object):
:return: result :return: result
:type: dict :type: dict
:raises NotImplemented: when not implemented. :raises NotImplemented: when not implemented.
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
raise NotImplementedError("Fails with err_code == 0 with HS110.") raise NotImplementedError("Fails with err_code == 0 with HS110.")
""" """
@ -252,7 +254,7 @@ class SmartDevice(object):
:return: Timezone information :return: Timezone information
:rtype: dict :rtype: dict
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
return self._query_helper("time", "get_timezone") return self._query_helper("time", "get_timezone")
@ -264,9 +266,10 @@ class SmartDevice(object):
:return: Information about hardware :return: Information about hardware
:rtype: dict :rtype: dict
""" """
keys = ["sw_ver", "hw_ver", "mac", "hwId", "fwId", "oemId", "dev_name"] keys = ["sw_ver", "hw_ver", "mac", "mic_mac", "type",
"mic_type", "hwId", "fwId", "oemId", "dev_name"]
info = self.sys_info info = self.sys_info
return {key: info[key] for key in keys} return {key: info[key] for key in keys if key in info}
@property @property
def location(self): def location(self):
@ -309,7 +312,15 @@ class SmartDevice(object):
:return: mac address in hexadecimal with colons, e.g. 01:23:45:67:89:ab :return: mac address in hexadecimal with colons, e.g. 01:23:45:67:89:ab
:rtype: str :rtype: str
""" """
return self.sys_info["mac"] info = self.sys_info
if 'mac' in info:
return info["mac"]
elif 'mic_mac' in info:
return info['mic_mac']
else:
raise SmartDeviceException("Unknown mac, please submit a bug"
"with sysinfo output.")
@mac.setter @mac.setter
def mac(self, mac): def mac(self, mac):
@ -317,7 +328,7 @@ class SmartDevice(object):
Sets new mac address Sets new mac address
:param str mac: mac in hexadecimal with colons, e.g. 01:23:45:67:89:ab :param str mac: mac in hexadecimal with colons, e.g. 01:23:45:67:89:ab
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
self._query_helper("system", "set_mac_addr", {"mac": mac}) self._query_helper("system", "set_mac_addr", {"mac": mac})
@ -328,7 +339,7 @@ class SmartDevice(object):
:returns: current readings or False :returns: current readings or False
:rtype: dict, False :rtype: dict, False
False if device has no energy meter or error occured False if device has no energy meter or error occured
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
if not self.has_emeter: if not self.has_emeter:
return False return False
@ -345,7 +356,7 @@ class SmartDevice(object):
:return: mapping of day of month to value :return: mapping of day of month to value
False if device has no energy meter or error occured False if device has no energy meter or error occured
:rtype: dict :rtype: dict
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
if not self.has_emeter: if not self.has_emeter:
return False return False
@ -374,7 +385,7 @@ class SmartDevice(object):
:return: dict: mapping of month to value :return: dict: mapping of month to value
False if device has no energy meter False if device has no energy meter
:rtype: dict :rtype: dict
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
if not self.has_emeter: if not self.has_emeter:
return False return False
@ -397,7 +408,7 @@ class SmartDevice(object):
:return: True if statistics were deleted :return: True if statistics were deleted
False if device has no energy meter. False if device has no energy meter.
:rtype: bool :rtype: bool
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
if not self.has_emeter: if not self.has_emeter:
return False return False
@ -414,7 +425,7 @@ class SmartDevice(object):
:return: the current power consumption in Watt. :return: the current power consumption in Watt.
False if device has no energy meter. False if device has no energy meter.
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
if not self.has_emeter: if not self.has_emeter:
return False return False
@ -466,3 +477,7 @@ class SmartDevice(object):
:rtype: dict :rtype: dict
""" """
raise NotImplementedError("Device subclass needs to implement this.") raise NotImplementedError("Device subclass needs to implement this.")
def __repr__(self):
return "<%s at %s: %s>" % (self.__class__.__name__,
self.ip_address, self.identify())

View File

@ -1,7 +1,7 @@
import datetime import datetime
import logging import logging
from .pyHS100 import SmartDevice from pyHS100 import SmartDevice
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -19,7 +19,7 @@ class SmartPlug(SmartDevice):
# query and print current state of plug # query and print current state of plug
print(p.state) print(p.state)
Errors reported by the device are raised as SmartPlugExceptions, Errors reported by the device are raised as SmartDeviceExceptions,
and should be handled by the user of the library. and should be handled by the user of the library.
Note: Note:
@ -65,7 +65,7 @@ class SmartPlug(SmartDevice):
SWITCH_STATE_ON SWITCH_STATE_ON
SWITCH_STATE_OFF SWITCH_STATE_OFF
:raises ValueError: on invalid state :raises ValueError: on invalid state
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
if not isinstance(value, str): if not isinstance(value, str):
@ -90,7 +90,7 @@ class SmartPlug(SmartDevice):
""" """
Turn the switch on. Turn the switch on.
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
self._query_helper("system", "set_relay_state", {"state": 1}) self._query_helper("system", "set_relay_state", {"state": 1})
@ -98,7 +98,7 @@ class SmartPlug(SmartDevice):
""" """
Turn the switch off. Turn the switch off.
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
self._query_helper("system", "set_relay_state", {"state": 0}) self._query_helper("system", "set_relay_state", {"state": 0})
@ -125,7 +125,7 @@ class SmartPlug(SmartDevice):
Sets the state of the led (night mode) Sets the state of the led (night mode)
:param bool state: True to set led on, False to set led off :param bool state: True to set led on, False to set led off
:raises SmartPlugException: on error :raises SmartDeviceException: on error
""" """
self._query_helper("system", "set_led_off", {"off": int(not state)}) self._query_helper("system", "set_led_off", {"off": int(not state)})

View File

@ -3,7 +3,7 @@ import enum
class SmartDeviceException(Exception): class SmartDeviceException(Exception):
""" """
SmartPlugException gets raised for errors reported by the plug. SmartDeviceException gets raised for errors reported by the plug.
""" """
pass pass