mirror of
https://github.com/python-kasa/python-kasa.git
synced 2024-12-22 19:23:34 +00:00
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:
parent
d7aade4e61
commit
07af48e41a
@ -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
|
||||
|
@ -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
68
pyHS100/discover.py
Normal 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
|
@ -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):
|
||||
"""
|
||||
|
@ -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.
|
||||
|
||||
"""
|
||||
|
@ -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())
|
@ -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)})
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user