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.
"""
# flake8: noqa
from .smartdevice import SmartDevice
from .smartplug import SmartPlug
from .pyHS100 import SmartDevice
from .types import SmartDeviceException
from .smartbulb import SmartBulb
from .protocol import TPLinkSmartHomeProtocol
from .discover import Discover

View File

@ -2,7 +2,7 @@ import sys
import click
import logging
from click_datetime import Datetime
from pprint import pformat
from pprint import pformat as pf
if sys.version_info < (3, 4):
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)
from pyHS100 import (SmartDevice, SmartPlug, SmartBulb,
TPLinkSmartHomeProtocol) # noqa: E402
TPLinkSmartHomeProtocol, Discover) # noqa: E402
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('--debug/--normal', default=False)
@click.option('--bulb', default=False, is_flag=True)
@click.option('--plug', default=False, is_flag=True)
@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."""
if debug:
logging.basicConfig(level=logging.DEBUG)
@ -31,26 +32,46 @@ def cli(ctx, ip, debug, bulb):
return
if ip is None:
click.echo("You must specify the IP!")
sys.exit(-1)
click.echo("No IP given, trying discovery..")
ctx.invoke(discover)
return
if bulb:
dev = SmartBulb(ip)
else:
dev = SmartPlug(ip)
ctx.obj = dev
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)
elif plug:
dev = SmartPlug(ip)
else:
click.echo("Unable to detect type, use --bulb or --plug!")
return
ctx.obj = dev
if ctx.invoked_subcommand is None:
ctx.invoke(state)
@cli.command()
@click.option('--timeout', default=5, required=False)
def discover(timeout):
@click.option('--timeout', default=3, required=False)
@click.option('--discover-only', default=False)
@click.pass_context
def discover(ctx, timeout, discover_only):
"""Discover devices in the network."""
click.echo("Discovering devices for %s seconds" % timeout)
for dev in TPLinkSmartHomeProtocol.discover(timeout=timeout):
print("Found device: %s" % pformat(dev))
found_devs = Discover.discover(timeout=timeout).items()
if not discover_only:
for ip, dev in found_devs:
ctx.obj = dev
ctx.invoke(state)
print()
return found_devs
@cli.command()
@ -58,7 +79,7 @@ def discover(timeout):
def sysinfo(dev):
"""Print out full system information."""
click.echo(click.style("== System info ==", bold=True))
click.echo(pformat(dev.sys_info))
click.echo(pf(dev.sys_info))
@cli.command()
@ -71,6 +92,7 @@ def state(ctx, dev):
click.echo(click.style("Device state: %s" % "ON" if dev.is_on else "OFF",
fg="green" if dev.is_on else "red"))
click.echo("IP address: %s" % dev.ip_address)
for k, v in dev.state_information.items():
click.echo("%s: %s" % (k, v))
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)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5.0)
sock.settimeout(TPLinkSmartHomeProtocol.DEFAULT_TIMEOUT)
try:
sock.connect((host, port))
@ -74,50 +74,6 @@ class TPLinkSmartHomeProtocol:
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
def encrypt(request):
"""

View File

@ -1,4 +1,4 @@
from .pyHS100 import SmartDevice
from pyHS100 import SmartDevice
class SmartBulb(SmartDevice):
@ -32,7 +32,7 @@ class SmartBulb(SmartDevice):
# check the current 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.
"""

View File

@ -35,7 +35,6 @@ class SmartDevice(object):
Create a new SmartDevice instance, identified through its IP address.
: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)
self.ip_address = ip_address
@ -52,7 +51,7 @@ class SmartDevice(object):
:param arg: JSON object passed as parameter to the command
:return: Unwrapped result for the call.
:rtype: dict
:raises SmartPlugException: if command was not executed correctly
:raises SmartDeviceException: if command was not executed correctly
"""
if arg is None:
arg = {}
@ -121,7 +120,7 @@ class SmartDevice(object):
:return: sysinfo
:rtype dict
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
return self._query_helper("system", "get_sysinfo")
@ -146,7 +145,7 @@ class SmartDevice(object):
:return: device model
:rtype: str
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
return self.sys_info['model']
@ -166,7 +165,7 @@ class SmartDevice(object):
Sets the device name aka alias.
:param alias: New alias (name)
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
self._query_helper("system", "set_dev_alias", {"alias": alias})
@ -179,7 +178,7 @@ class SmartDevice(object):
:return: icon and its hash
:rtype: dict
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
return self._query_helper("system", "get_dev_icon")
@ -204,12 +203,15 @@ class SmartDevice(object):
Returns current time from the device.
:return: datetime for device's time
:rtype: datetime.datetime
:raises SmartPlugException: on error
:rtype: datetime.datetime or None when not available
:raises SmartDeviceException: on error
"""
res = self._query_helper("time", "get_time")
return datetime.datetime(res["year"], res["month"], res["mday"],
res["hour"], res["min"], res["sec"])
try:
res = self._query_helper("time", "get_time")
return datetime.datetime(res["year"], res["month"], res["mday"],
res["hour"], res["min"], res["sec"])
except SmartDeviceException:
return None
@time.setter
def time(self, ts):
@ -221,7 +223,7 @@ class SmartDevice(object):
:return: result
:type: dict
:raises NotImplemented: when not implemented.
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
raise NotImplementedError("Fails with err_code == 0 with HS110.")
"""
@ -252,7 +254,7 @@ class SmartDevice(object):
:return: Timezone information
:rtype: dict
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
return self._query_helper("time", "get_timezone")
@ -264,9 +266,10 @@ class SmartDevice(object):
:return: Information about hardware
: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
return {key: info[key] for key in keys}
return {key: info[key] for key in keys if key in info}
@property
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
: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
def mac(self, mac):
@ -317,7 +328,7 @@ class SmartDevice(object):
Sets new mac address
: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})
@ -328,7 +339,7 @@ class SmartDevice(object):
:returns: current readings or False
:rtype: dict, False
False if device has no energy meter or error occured
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
if not self.has_emeter:
return False
@ -345,7 +356,7 @@ class SmartDevice(object):
:return: mapping of day of month to value
False if device has no energy meter or error occured
:rtype: dict
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
if not self.has_emeter:
return False
@ -374,7 +385,7 @@ class SmartDevice(object):
:return: dict: mapping of month to value
False if device has no energy meter
:rtype: dict
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
if not self.has_emeter:
return False
@ -397,7 +408,7 @@ class SmartDevice(object):
:return: True if statistics were deleted
False if device has no energy meter.
:rtype: bool
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
if not self.has_emeter:
return False
@ -414,7 +425,7 @@ class SmartDevice(object):
:return: the current power consumption in Watt.
False if device has no energy meter.
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
if not self.has_emeter:
return False
@ -466,3 +477,7 @@ class SmartDevice(object):
:rtype: dict
"""
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 logging
from .pyHS100 import SmartDevice
from pyHS100 import SmartDevice
_LOGGER = logging.getLogger(__name__)
@ -19,7 +19,7 @@ class SmartPlug(SmartDevice):
# query and print current state of plug
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.
Note:
@ -65,7 +65,7 @@ class SmartPlug(SmartDevice):
SWITCH_STATE_ON
SWITCH_STATE_OFF
:raises ValueError: on invalid state
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
if not isinstance(value, str):
@ -90,7 +90,7 @@ class SmartPlug(SmartDevice):
"""
Turn the switch on.
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
self._query_helper("system", "set_relay_state", {"state": 1})
@ -98,7 +98,7 @@ class SmartPlug(SmartDevice):
"""
Turn the switch off.
:raises SmartPlugException: on error
:raises SmartDeviceException: on error
"""
self._query_helper("system", "set_relay_state", {"state": 0})
@ -125,7 +125,7 @@ class SmartPlug(SmartDevice):
Sets the state of the led (night mode)
: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)})

View File

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