Skip to content

Commit

Permalink
Merge 2fb9499 into 166f6ef
Browse files Browse the repository at this point in the history
  • Loading branch information
mattsaxon committed Jan 3, 2020
2 parents 166f6ef + 2fb9499 commit b43d37f
Show file tree
Hide file tree
Showing 17 changed files with 1,075 additions and 692 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
@@ -1,8 +1,8 @@
language: python
python:
- 3.8
- 3.7
- 3.6
- 3.5
sudo: required
dist: xenial
install: pip install -U tox-travis coveralls
Expand Down
15 changes: 15 additions & 0 deletions .vscode/launch.json
@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python: Current File",
"type": "python",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
}
]
}
18 changes: 14 additions & 4 deletions AUTHORS.rst
Expand Up @@ -2,12 +2,22 @@
Credits
=======

Development Lead
----------------
Development Leads
-----------------

* Matt Saxon <saxonmatt@hotmail.com>

* Andrew Beveridge <andrew@beveridge.uk>

Contributors
------------

None yet. Why not be the first?
* Gustavo Sverzut Barbieri https://github.com/barbieri : assisted with a number of broken code issues


Acknolwledgements
-----------------

* Andrew Beveridge <andrew@beveridge.uk>

This R3 version was prompted by the excellent work reverse engineering the original firmware by Andrew
Additionally, whilst the workings of the R3 firmware is completely different, this project is structured on Andrew's work
60 changes: 32 additions & 28 deletions README.rst
@@ -1,40 +1,52 @@
===========
pySonoffLAN
===========
=============
pySonoffLANR3
=============


.. image:: https://img.shields.io/pypi/v/pysonofflan.svg
:target: https://pypi.python.org/pypi/pysonofflan
.. image:: https://img.shields.io/pypi/v/pysonofflanr3.svg
:target: https://pypi.python.org/pypi/pysonofflanr3
:alt: Latest PyPi Release

.. image:: https://img.shields.io/pypi/pyversions/pysonofflan.svg?style=flat
:target: https://pypi.python.org/pypi/pysonofflan
.. image:: https://img.shields.io/pypi/pyversions/pysonofflanr3.svg?style=flat
:target: https://pypi.python.org/pypi/pysonofflanr3
:alt: Supported Python Versions

.. image:: https://img.shields.io/travis/beveradb/pysonofflan.svg
:target: https://travis-ci.org/beveradb/pysonofflan
.. image:: https://img.shields.io/travis/mattsaxon/pysonofflan.svg
:target: https://travis-ci.org/mattsaxon/pysonofflan
:alt: Build Status

.. image:: https://readthedocs.org/projects/pysonofflan/badge/?version=latest
:target: https://pysonofflan.readthedocs.io/en/latest/?badge=latest
.. image:: https://readthedocs.org/projects/pysonofflanr3/badge/?version=latest
:target: https://pysonofflanr3.readthedocs.io/en/latest/?badge=latest
:alt: Documentation Status

.. image:: https://coveralls.io/repos/github/beveradb/pysonofflan/badge.svg?branch=master
:target: https://coveralls.io/github/beveradb/pysonofflan?branch=master
.. image:: https://coveralls.io/repos/github/mattsaxon/pysonofflan/badge.svg
:target: https://coveralls.io/github/mattsaxon/pysonofflan
:alt: Code Coverage

.. image:: https://img.shields.io/pypi/wheel/pysonofflan.svg
:target: https://pypi.org/project/pysonofflan/#files
.. image:: https://img.shields.io/pypi/wheel/pysonofflanr3.svg
:target: https://pypi.org/project/pysonofflanr3/#files
:alt: Has Wheel Package

.. image:: https://pyup.io/repos/github/beveradb/pysonofflan/shield.svg
:target: https://pyup.io/repos/github/beveradb/pysonofflan/
.. image:: https://pyup.io/repos/github/mattsaxon/pysonofflan/shield.svg
:target: https://pyup.io/repos/github/mattsaxon/pysonofflan/
:alt: Updates

.. image:: https://pyup.io/repos/github/mattsaxon/pysonofflan/python-3-shield.svg
:target: https://pyup.io/repos/github/mattsaxon/pysonofflan/
:alt: Python 3

.. image:: https://img.shields.io/badge/say-thanks-ff69b4.svg
:target: https://www.buymeacoffee.com/XTOsBAc
:alt: Say Thanks

Control Sonoff devices running original firmware, in LAN mode.
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

To control Sonoff switches running the V3+ Itead firmware (tested on 3.0, 3.0.1, 3.1.0, 3.3.0), locally (LAN mode).

**This will only work for Sonoff devices running V3+ of the stock (Itead / eWeLink) firmware. For users of V1.8.0 - V2.6.1, please see the code in this repository https://github.com/beveradb/pysonofflan**


This module provides a way to interface with Sonoff smart home devices,
such as smart switches (e.g. Sonoff Basic), plugs (e.g. Sonoff S20),
and wall switches (e.g. Sonoff Touch), when these devices are in LAN Mode.
Expand All @@ -49,15 +61,6 @@ Since mid 2018, the firmware Itead have shipped with most Sonoff devices
has provided this feature, allowing devices to be controlled directly
on the local network using a WebSocket connection on port 8081.

The feature is designed to only be used when there is no connection
to the Itead cloud servers, (e.g. if your internet connection is down,
or their servers are down).
As such, it is only enabled when the device is connected to your WiFi
network, but *unable to reach the Itead servers*.

Most users will only be able to use this by **deliberately
blocking internet access** to their Sonoff devices.

Features
--------

Expand Down Expand Up @@ -94,7 +97,8 @@ Command-Line Usage
Inching/Momentary switch.
-v, --verbosity LVL Either CRITICAL, ERROR, WARNING, INFO or DEBUG
--help Show this message and exit.

--api_key KEY Needed fro devices not in DIY mode. See https://github.com/mattsaxon/pysonofflan/wiki/Finding-the-API__Key
Commands:
discover Discover devices in the network (takes ~1...
listen Connect to device, print state, then print...
Expand Down
30 changes: 30 additions & 0 deletions debug/Decrypt.py
@@ -0,0 +1,30 @@
import time
import requests
import json

from zeroconf import ServiceBrowser, Zeroconf

from Crypto.Hash import MD5
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from base64 import b64decode

class SonoffR3:

def __init__(self, Apikey):

h = MD5.new()
h.update(Apikey)
self._key = h.digest()

def Decrypt(self, encoded, iv):

cipher = AES.new(self._key, AES.MODE_CBC, iv=b64decode(iv))
ciphertext = b64decode(encoded)
padded = cipher.decrypt(ciphertext)
plaintext = unpad(padded, AES.block_size)

return plaintext

device = SonoffR3(b'apikey')
print(device.Decrypt(b'ciphertext', b'iv'))
89 changes: 23 additions & 66 deletions pysonofflan/cli.py
Expand Up @@ -55,34 +55,25 @@ def format(self, record):
help='IP address or hostname of the device to connect to.')
@click.option('--device_id', envvar="PYSONOFFLAN_device_id", required=False,
help='Device ID of the device to connect to.')
@click.option('--api_key', envvar="PYSONOFFLAN_api_key", required=False,
help='api key for the device to connect to.')
@click.option('--inching', envvar="PYSONOFFLAN_inching", required=False,
help='Number of seconds of "on" time if this is an '
'Inching/Momentary switch.')
@click.pass_context
@click_log.simple_verbosity_option(logger, '--loglevel', '-l')
@click.version_option()
def cli(ctx, host, device_id, inching):
def cli(ctx, host, device_id, api_key, inching):
"""A cli tool for controlling Sonoff Smart Switches/Plugs in LAN Mode."""
if ctx.invoked_subcommand == "discover":
return

if device_id is not None and host is None:
logger.info(
"Device ID is given, using discovery to find host %s" % device_id)
host = find_host_from_device_id(device_id=device_id)
if host:
logger.info("Matching Device ID found! IP: %s" % host)
else:
logger.info("No device with name %s found" % device_id)
return

if host is None:
logger.error("No host name given, see usage below:")
if host is None and device_id is None:
logger.error("No host name or device_id given, see usage below:")
click.echo(ctx.get_help())
sys.exit(1)

ctx.obj = {"host": host, "inching": inching}

ctx.obj = {"host": host, "device_id": device_id, "api_key": api_key, "inching": inching}

@cli.command()
def discover():
Expand All @@ -93,48 +84,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


def find_host_from_device_id(device_id):
"""Discover a device identified by its device_id"""
logger.info(
"Trying to discover %s by scanning for devices "
"on local network, please wait..." % device_id)
found_devices = asyncio.get_event_loop().run_until_complete(
Discover.discover(logger)).items()
for ip, _ in found_devices:
logger.info("Found Sonoff LAN Mode device at IP %s, attempting to "
"read state to get device ID" % ip)

shared_state = {'device_id_at_current_ip': None}

async def device_id_callback(device: SonoffSwitch):
if device.basic_info is not None:
device.shared_state['device_id_at_current_ip'] = \
device.device_id
device.shutdown_event_loop()

SonoffSwitch(
host=ip,
callback_after_update=device_id_callback,
shared_state=shared_state,
logger=logger
)

current_device_id = shared_state['device_id_at_current_ip']

if device_id.lower() == current_device_id.lower():
return ip
else:
logger.info(
"Found device ID %s which did not match" % current_device_id
)
return None

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 All @@ -152,22 +103,23 @@ async def state_callback(device):
SonoffSwitch(
host=config['host'],
callback_after_update=state_callback,
logger=logger
logger=logger,
device_id=config['device_id'],
api_key=config['api_key']
)


@cli.command()
@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 All @@ -192,7 +144,9 @@ async def state_callback(self):
host=config['host'],
callback_after_update=state_callback,
shared_state=shared_state,
logger=logger
logger=logger,
device_id=config['device_id'],
api_key=config['api_key']
)


Expand All @@ -211,8 +165,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 @@ -239,12 +193,15 @@ 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
logger=logger,
device_id=config['device_id'],
api_key=config['api_key']
)


if __name__ == "__main__":
# pylint: disable=no-value-for-parameter
cli()

0 comments on commit b43d37f

Please sign in to comment.