Skip to content

Commit

Permalink
Set of fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
mattsaxon committed Dec 30, 2019
1 parent f4071a0 commit 5b70158
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 74 deletions.
29 changes: 25 additions & 4 deletions debug/SonoffNetworkStub.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
class MyListener:

info = None
devices = {}

def remove_service(self, zeroconf, type, name):

Expand All @@ -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):
Expand All @@ -27,17 +32,33 @@ 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()





21 changes: 12 additions & 9 deletions pysonofflan/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -205,4 +207,5 @@ async def update_callback(device: SonoffSwitch):


if __name__ == "__main__":
# pylint: disable=no-value-for-parameter
cli()
3 changes: 2 additions & 1 deletion pysonofflan/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
109 changes: 55 additions & 54 deletions pysonofflan/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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



33 changes: 29 additions & 4 deletions pysonofflan/sonoffdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import asyncio
import json
import logging
import sys
from typing import Callable, Awaitable, Dict

import traceback
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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:
Expand All @@ -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()')
Expand All @@ -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')

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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')

Expand Down Expand Up @@ -348,6 +372,7 @@ def shutdown_exception_handler(loop, context):
)
self.loop.close()


@property
def device_id(self) -> str:
"""
Expand Down
10 changes: 8 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()

0 comments on commit 5b70158

Please sign in to comment.