From 5b70158e68253ec351973bfe33056d86e35e902d Mon Sep 17 00:00:00 2001 From: mattsaxon Date: Mon, 30 Dec 2019 04:23:46 +0000 Subject: [PATCH] Set of fixes --- debug/SonoffNetworkStub.py | 29 ++++++++-- pysonofflan/cli.py | 21 ++++--- pysonofflan/client.py | 3 +- pysonofflan/discover.py | 109 ++++++++++++++++++------------------ pysonofflan/sonoffdevice.py | 33 +++++++++-- tests/test_cli.py | 10 +++- 6 files changed, 131 insertions(+), 74 deletions(-) diff --git a/debug/SonoffNetworkStub.py b/debug/SonoffNetworkStub.py index 341d97d..a070c4c 100644 --- a/debug/SonoffNetworkStub.py +++ b/debug/SonoffNetworkStub.py @@ -5,6 +5,7 @@ class MyListener: info = None + devices = {} def remove_service(self, zeroconf, type, name): @@ -13,12 +14,16 @@ def remove_service(self, zeroconf, type, name): def add_service(self, zeroconf, type, name): + # if name == 'eWeLink_100065a8e3._ewelink._tcp.local.': print("%s - Service %s added" % (datetime.now(), name) ) + info = zeroconf.get_service_info(type, name) + print(info) + device = info.properties[b'id'].decode('ascii') + ip = self.parseAddress(info.address) + ":" + str(info.port) + + self.devices[device] = ip - # - # print(zeroconf.get_service_info(type, name)) - ServiceBrowser(zeroconf, name, listener) def update_service(self, zeroconf, type, name): @@ -27,13 +32,28 @@ def update_service(self, zeroconf, type, name): print("%s - Service %s updated" % (datetime.now(), name) ) # print(zeroconf.get_service_info(type, name)) - + + def parseAddress(self, address): + """ + Resolve the IP address of the device + :param address: + :return: add_str + """ + add_list = [] + for i in range(4): + add_list.append(int(address.hex()[(i * 2):(i + 1) * 2], 16)) + add_str = str(add_list[0]) + "." + str(add_list[1]) + \ + "." + str(add_list[2]) + "." + str(add_list[3]) + return add_str + + zeroconf = Zeroconf() listener = MyListener() browser = ServiceBrowser(zeroconf, "_ewelink._tcp.local.", listener) try: input("Press enter to exit...\n\n") + print(listener.devices) finally: zeroconf.close() @@ -41,3 +61,4 @@ def update_service(self, zeroconf, type, name): + diff --git a/pysonofflan/cli.py b/pysonofflan/cli.py index 5924150..a18b982 100644 --- a/pysonofflan/cli.py +++ b/pysonofflan/cli.py @@ -6,6 +6,10 @@ import click_log from click_log import ClickHandler +# ensure I can find this package even when it hasn't been installed (for development purposes) +import sys +sys.path.insert(0,'..') + from pysonofflan import (SonoffSwitch, Discover) if sys.version_info < (3, 5): @@ -84,10 +88,8 @@ def discover(): ) found_devices = asyncio.get_event_loop().run_until_complete( Discover.discover(logger)).items() - for ip, found_device_id in found_devices: - logger.info("Found Sonoff LAN Mode device at IP %s" % ip) - - return found_devices + for found_device_id, ip in found_devices: + logger.debug("Found Sonoff LAN Mode device %s at socket %s" % (found_device_id,ip)) @cli.command() @pass_config @@ -115,13 +117,13 @@ async def state_callback(device): @pass_config def on(config: dict): """Turn the device on.""" - switch_device(config['host'], config['inching'], 'on') + switch_device(config, config['inching'], 'on') @cli.command() @pass_config def off(config: dict): """Turn the device off.""" - switch_device(config['host'], config['inching'], 'off') + switch_device(config, config['inching'], 'off') @cli.command() @@ -167,8 +169,8 @@ def print_device_details(device): ) -def switch_device(host, inching, new_state): - logger.info("Initialising SonoffSwitch with host %s" % host) +def switch_device(config: dict, inching, new_state): + logger.info("Initialising SonoffSwitch with host %s" % config['host']) async def update_callback(device: SonoffSwitch): if device.basic_info is not None: @@ -195,7 +197,7 @@ async def update_callback(device: SonoffSwitch): "%ss" % inching) SonoffSwitch( - host=host, + host=config['host'], callback_after_update=update_callback, inching_seconds=int(inching) if inching else None, logger=logger, @@ -205,4 +207,5 @@ async def update_callback(device: SonoffSwitch): if __name__ == "__main__": +# pylint: disable=no-value-for-parameter cli() diff --git a/pysonofflan/client.py b/pysonofflan/client.py index 2cc3a00..ec18cdb 100644 --- a/pysonofflan/client.py +++ b/pysonofflan/client.py @@ -373,7 +373,8 @@ def decrypt(self, data_element, iv): except Exception as ex: self.logger.error('Error decrypting for device %s: %s, probably wrong API key', self.device_id, format(ex)) - + raise + return plaintext diff --git a/pysonofflan/discover.py b/pysonofflan/discover.py index a5b8705..e3b3651 100644 --- a/pysonofflan/discover.py +++ b/pysonofflan/discover.py @@ -2,79 +2,80 @@ import logging import socket import threading +import time from itertools import chain from typing import Dict +from datetime import datetime +from zeroconf import ServiceBrowser, Zeroconf class Discover: - SONOFF_PORT = 8081 @staticmethod - async def discover(logger=None) -> Dict[str, str]: + async def discover(logger=None, seconds_to_wait=None) -> Dict[str, str]: """ - Attempts websocket connection on port 8081 to all IP addresses on - common home IP subnets: 192.168.0.X and 192.168.1.X, in the hope of - detecting available supported devices in the local network. - :rtype: dict - :return: Array of devices {"ip": "device_id"} + :return: Array of devices {"device_id", "ip:port"} """ if logger is None: + print("new logger!") logger = logging.getLogger(__name__) - logger.debug("Attempting connection to all IPs on local network.") - devices = {} - threads = [] + logger.debug("Looking for all eWeLink devices on local network.") - try: - local_ip_ranges = chain( - ipaddress.IPv4Network('127.0.0.1/32'), - ipaddress.IPv4Network('192.168.0.0/24'), - ipaddress.IPv4Network('192.168.1.0/24') - ) + zeroconf = Zeroconf() + listener = MyListener() + listener.logger = logger + ServiceBrowser(zeroconf, "_ewelink._tcp.local.", listener) - # Spawn thread per IP address to scan - for ip in local_ip_ranges: - t = threading.Thread(target=Discover.probe_ip, - args=(logger, ip, devices)) - threads.append(t) + if seconds_to_wait == None: + time.sleep(1) - # Start all threads - for thread in threads: - thread.start() + else: + time.sleep(seconds_to_wait) - # Lock the main thread until all threads complete - for thread in threads: - thread.join() + zeroconf.close() - except Exception as ex: - logger.error("Caught Exception: %s" % ex, exc_info=False) + return listener.devices - return devices - @staticmethod - def probe_ip(logger, ip, devices): - """ - Attempt connection to IP address on specified port, adding this IP - to the devices dict if the connection was successful +class MyListener: + + def __init__(self): + + self.devices = {} + + + def remove_service(self, zeroconf, type, name): + + print("%s - Service %s removed" % (datetime.now(), name) ) + - :param logger: Logger instance to output debug messages on - :param ip: IP address to test - :param devices: Dict to insert IP into if connectable + def add_service(self, zeroconf, type, name): + + self.logger.debug("%s - Service %s added" % (datetime.now(), name) ) + info = zeroconf.get_service_info(type, name) + self.logger.debug(info) + device = info.properties[b'id'].decode('ascii') + ip = self.parseAddress(info.address) + ":" + str(info.port) + + self.logger.info("Found Sonoff LAN Mode device %s at socket %s" % (device, ip)) + + self.devices[device] = ip + + + def parseAddress(self, address): """ - logger.debug( - "Attempting connection to IP: %s on port %s" % ( - ip, Discover.SONOFF_PORT) - ) - tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - tcp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - tcp_sock.settimeout(0.5) - result = tcp_sock.connect_ex((str(ip), Discover.SONOFF_PORT)) - if result == 0: - logger.debug( - "Found open port %s at local IP: %s" % ( - Discover.SONOFF_PORT, - ip - ) - ) - devices[ip] = ip + Resolve the IP address of the device + :param address: + :return: add_str + """ + add_list = [] + for i in range(4): + add_list.append(int(address.hex()[(i * 2):(i + 1) * 2], 16)) + add_str = str(add_list[0]) + "." + str(add_list[1]) + \ + "." + str(add_list[2]) + "." + str(add_list[3]) + return add_str + + + diff --git a/pysonofflan/sonoffdevice.py b/pysonofflan/sonoffdevice.py index d476169..877f76e 100644 --- a/pysonofflan/sonoffdevice.py +++ b/pysonofflan/sonoffdevice.py @@ -5,6 +5,7 @@ import asyncio import json import logging +import sys from typing import Callable, Awaitable, Dict import traceback @@ -48,6 +49,24 @@ def __init__(self, else: self.logger = logger + # Ctrl-C (KeyboardInterrupt) does not work well on Windows + # This module solve that issue with wakeup coroutine. + # https://stackoverflow.com/questions/24774980/why-cant-i-catch-sigint-when-asyncio-event-loop-is-running/24775107#24775107 + # code lifted from https://gist.github.com/lambdalisue/05d5654bd1ec04992ad316d50924137c + if sys.platform.startswith('win'): + def hotfix(loop: asyncio.AbstractEventLoop) -> asyncio.AbstractEventLoop: + loop.call_soon(_wakeup, loop, 1.0) + return loop + + def _wakeup(loop: asyncio.AbstractEventLoop, delay: float=1.0) -> None: + loop.call_later(delay, _wakeup, loop, delay) + + else: + # Do Nothing on non Windows + def hotfix(loop: asyncio.AbstractEventLoop) -> asyncio.AbstractEventLoop: + return loop + + try: if self.loop is None: @@ -83,11 +102,13 @@ def __init__(self, self.tasks.append(self.send_updated_params_task) if self.new_loop: + hotfix(self.loop) ## see Cltr-C hotfix earlier in routine self.loop.run_until_complete(self.send_updated_params_task) except asyncio.CancelledError: self.logger.debug('SonoffDevice loop ended, returning') + def calculate_retry(self, retry_count): try: @@ -104,6 +125,7 @@ def calculate_retry(self, retry_count): self.logger.error('Unexpected error in wait_before_retry(): %s', format(ex)) + async def send_availability_loop(self): self.logger.debug('enter send_availability_loop()') @@ -118,8 +140,9 @@ async def send_availability_loop(self): self.logger.debug('connected event, sending update') - if self.callback_after_update is not None: - await self.callback_after_update(self) + # this update doesn't need to be sent as handle_message will call it at the end + #if self.callback_after_update is not None: + # await self.callback_after_update(self) self.logger.debug('waiting for disconnection') @@ -214,6 +237,7 @@ async def send_updated_params_loop(self): finally: self.logger.debug('send_updated_params_loop finally block reached') + def update_params(self, params): if self.params != params: @@ -225,6 +249,7 @@ def update_params(self, params): else: self.logger.debug('unnecessary update received, ignoring') + async def handle_message(self, message): """ Receive message sent by the device and handle it, either updating @@ -292,8 +317,7 @@ async def handle_message(self, message): if send_update and self.callback_after_update is not None: await self.callback_after_update(self) - - + def shutdown_event_loop(self): self.logger.debug('shutdown_event_loop called') @@ -348,6 +372,7 @@ def shutdown_exception_handler(loop, context): ) self.loop.close() + @property def device_id(self) -> str: """ diff --git a/tests/test_cli.py b/tests/test_cli.py index 1c59f00..08a8970 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,8 +7,11 @@ from click.testing import CliRunner -from pysonofflan import cli +# ensure I can find this package even when it hasn't been installed (for development purposes) +import sys +sys.path.insert(0, '..') +from pysonofflan import cli class TestCLI(unittest.TestCase): """Tests for pysonofflan CLI interface.""" @@ -75,5 +78,8 @@ def test_cli_discover_debug(self): """Test the CLI.""" runner = CliRunner() result = runner.invoke(cli.cli, ['-l', 'DEBUG', 'discover']) - assert "Attempting connection to IP: 192.168.0.1 on port 8081" in \ + assert "Looking for all eWeLink devices on local network" in \ result.output + +if __name__ == '__main__': + unittest.main() \ No newline at end of file