Added ESP8266/ESP32 integration (closes #108)
This commit is contained in:
parent
02607bae97
commit
c3c88b23fe
10 changed files with 1827 additions and 0 deletions
5
docs/source/platypush/plugins/esp.rst
Normal file
5
docs/source/platypush/plugins/esp.rst
Normal file
|
@ -0,0 +1,5 @@
|
|||
``platypush.plugins.esp``
|
||||
=========================
|
||||
|
||||
.. automodule:: platypush.plugins.esp
|
||||
:members:
|
|
@ -25,6 +25,7 @@ Plugins
|
|||
platypush/plugins/clipboard.rst
|
||||
platypush/plugins/db.rst
|
||||
platypush/plugins/dropbox.rst
|
||||
platypush/plugins/esp.rst
|
||||
platypush/plugins/file.rst
|
||||
platypush/plugins/foursquare.rst
|
||||
platypush/plugins/google.rst
|
||||
|
|
50
platypush/message/response/esp.py
Normal file
50
platypush/message/response/esp.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
from typing import Optional
|
||||
|
||||
from platypush.message import Mapping
|
||||
|
||||
|
||||
class EspWifiScanResult(Mapping):
|
||||
def __init__(self,
|
||||
essid: str,
|
||||
bssid: str,
|
||||
channel: int,
|
||||
rssi: int,
|
||||
auth_mode: int,
|
||||
hidden: bool,
|
||||
*args,
|
||||
**kwargs):
|
||||
self.essid = essid
|
||||
self.bssid = bssid
|
||||
self.channel = channel
|
||||
self.rssi = rssi
|
||||
self.auth_mode = auth_mode
|
||||
self.hidden = hidden
|
||||
super().__init__(*args, **dict(self), **kwargs)
|
||||
|
||||
|
||||
class EspWifiConfigResult(Mapping):
|
||||
def __init__(self,
|
||||
ip: str,
|
||||
netmask: str,
|
||||
gateway: str,
|
||||
dns: str,
|
||||
mac: str,
|
||||
active: bool,
|
||||
essid: Optional[str] = None,
|
||||
channel: Optional[int] = None,
|
||||
hidden: Optional[bool] = None,
|
||||
*args,
|
||||
**kwargs):
|
||||
self.ip = ip
|
||||
self.netmask = netmask
|
||||
self.gateway = gateway
|
||||
self.dns = dns
|
||||
self.mac = mac
|
||||
self.active = active
|
||||
self.essid = essid
|
||||
self.channel = channel
|
||||
self.hidden = hidden
|
||||
super().__init__(*args, **dict(self), **kwargs)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
1538
platypush/plugins/esp/__init__.py
Normal file
1538
platypush/plugins/esp/__init__.py
Normal file
File diff suppressed because it is too large
Load diff
0
platypush/plugins/esp/models/__init__.py
Normal file
0
platypush/plugins/esp/models/__init__.py
Normal file
173
platypush/plugins/esp/models/connection.py
Normal file
173
platypush/plugins/esp/models/connection.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
import enum
|
||||
import re
|
||||
import threading
|
||||
from typing import Optional, Union
|
||||
|
||||
from platypush.utils import grouper
|
||||
|
||||
|
||||
class Connection:
|
||||
"""
|
||||
This class models the connection with an ESP8266/ESP32 device over its WebREPL websocket channel.
|
||||
"""
|
||||
|
||||
class State(enum.IntEnum):
|
||||
DISCONNECTED = 1
|
||||
CONNECTED = 2
|
||||
PASSWORD_REQUIRED = 3
|
||||
READY = 4
|
||||
SENDING_REQUEST = 5
|
||||
WAITING_ECHO = 6
|
||||
WAITING_RESPONSE = 7
|
||||
|
||||
def __init__(self, host: str, port: int, connect_timeout: Optional[float] = None,
|
||||
password: Optional[str] = None, ws=None):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.connect_timeout = connect_timeout
|
||||
self.password = password
|
||||
self.state = self.State.DISCONNECTED
|
||||
self.ws = ws
|
||||
self._connected = threading.Event()
|
||||
self._logged_in = threading.Event()
|
||||
self._echo_received = threading.Event()
|
||||
self._response_received = threading.Event()
|
||||
self._password_requested = False
|
||||
self._running_cmd = None
|
||||
self._received_echo = None
|
||||
self._received_response = None
|
||||
self._paste_header_received = False
|
||||
|
||||
def send(self, msg: Union[str, bytes], wait_response: bool = True, timeout: Optional[float] = None):
|
||||
bufsize = 255
|
||||
|
||||
msg = (msg
|
||||
.replace("\n", "\r\n") # end of command in normal mode
|
||||
.replace("\\x01", "\x01") # switch to raw mode
|
||||
.replace("\\x02", "\x02") # switch to normal mode
|
||||
.replace("\\x03", "\x03") # interrupt
|
||||
.replace("\\x04", "\x04") # end of command in raw mode
|
||||
.encode())
|
||||
|
||||
if not msg.endswith(b'\r\n'):
|
||||
msg += b'\r\n'
|
||||
if wait_response:
|
||||
# Enter PASTE mode and exit on end-of-message
|
||||
msg = b'\x05' + msg + b'\x04'
|
||||
|
||||
if wait_response:
|
||||
self.state = self.State.SENDING_REQUEST
|
||||
self._running_cmd = msg.decode().strip()
|
||||
self._received_echo = ''
|
||||
|
||||
self._response_received.clear()
|
||||
self._echo_received.clear()
|
||||
for chunk in grouper(bufsize, msg):
|
||||
self.ws.send(bytes(chunk))
|
||||
|
||||
if wait_response:
|
||||
self.state = self.State.WAITING_ECHO
|
||||
echo_received = self._echo_received.wait(timeout=timeout)
|
||||
if not echo_received:
|
||||
self.on_timeout('No response echo received')
|
||||
|
||||
self._paste_header_received = False
|
||||
response_received = self._response_received.wait(timeout=timeout)
|
||||
if not response_received:
|
||||
self.on_timeout('No response received')
|
||||
|
||||
response = self._received_response
|
||||
self._received_response = None
|
||||
return response
|
||||
|
||||
def on_connect(self):
|
||||
self.state = Connection.State.CONNECTED
|
||||
self._connected.set()
|
||||
|
||||
def on_password_requested(self):
|
||||
self._password_requested = True
|
||||
self.state = Connection.State.PASSWORD_REQUIRED
|
||||
assert self.password, 'This device is protected by password and no password was provided'
|
||||
self.send(self.password, wait_response=False)
|
||||
|
||||
def on_ready(self):
|
||||
self.state = Connection.State.READY
|
||||
self._logged_in.set()
|
||||
|
||||
def on_close(self):
|
||||
self.state = self.State.DISCONNECTED
|
||||
self._connected.clear()
|
||||
self._logged_in.clear()
|
||||
self._password_requested = False
|
||||
self.ws = None
|
||||
|
||||
def on_recv_echo(self, echo):
|
||||
def str_transform(s: str):
|
||||
s = s.replace('\x05', '').replace('\x04', '').replace('\r', '')
|
||||
s = re.sub('^[\s\r\n]+', '', s)
|
||||
s = re.sub('[\s\r\n]+$', '', s)
|
||||
return s
|
||||
|
||||
if echo.endswith('\r\n=== ') and not self._paste_header_received:
|
||||
self._paste_header_received = True
|
||||
return
|
||||
|
||||
if re.match('\s+>>>\s+', echo) \
|
||||
or re.match('\s+\.\.\.\s+', echo) \
|
||||
or re.match('\s+===\s+', echo):
|
||||
return
|
||||
|
||||
self._received_echo += echo
|
||||
running_cmd = str_transform(self._running_cmd)
|
||||
received_echo = str_transform(self._received_echo)
|
||||
|
||||
if running_cmd == received_echo:
|
||||
self._received_echo = None
|
||||
self.state = self.State.WAITING_RESPONSE
|
||||
self._echo_received.set()
|
||||
|
||||
def close(self):
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
self.ws.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
self.on_close()
|
||||
|
||||
def on_timeout(self, msg: str = ''):
|
||||
self.close()
|
||||
raise TimeoutError(msg)
|
||||
|
||||
def append_response(self, response):
|
||||
if isinstance(response, bytes):
|
||||
response = response.decode()
|
||||
|
||||
if not self._received_response:
|
||||
self._received_response = ''
|
||||
|
||||
self._received_response += response
|
||||
|
||||
def on_recv_response(self, response):
|
||||
self.append_response(response)
|
||||
self.state = self.State.READY
|
||||
self._received_response = self._received_response.strip()
|
||||
|
||||
if self._received_response.startswith('=== '):
|
||||
# Strip PASTE mode output residual
|
||||
self._received_response = self._received_response[4:]
|
||||
self._received_response = self._received_response.strip()
|
||||
|
||||
self._response_received.set()
|
||||
|
||||
def wait_ready(self):
|
||||
connected = self._connected.wait(timeout=self.connect_timeout)
|
||||
if not connected:
|
||||
self.on_timeout('Connection timed out')
|
||||
|
||||
logged_in = self._logged_in.wait(timeout=self.connect_timeout)
|
||||
if not logged_in:
|
||||
self.on_timeout('Log in timed out')
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
39
platypush/plugins/esp/models/device.py
Normal file
39
platypush/plugins/esp/models/device.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
from typing import Optional, List, Union
|
||||
|
||||
from platypush.message import Mapping
|
||||
|
||||
|
||||
class Pin(Mapping):
|
||||
"""
|
||||
This class models the configuration for the PIN of a device.
|
||||
"""
|
||||
def __init__(self, number: int, name: Optional[str] = None, pwm: bool = False, pull_up: bool = False):
|
||||
super().__init__(number=number, name=name, pwm=pwm, pull_up=pull_up)
|
||||
|
||||
|
||||
class Device(Mapping):
|
||||
"""
|
||||
This class models the properties of a configured ESP device.
|
||||
"""
|
||||
def __init__(self, host: str, port: int = 8266, password: Optional[str] = None,
|
||||
name: Optional[str] = None, pins: List[Union[Pin, dict]] = None):
|
||||
pins = [
|
||||
pin if isinstance(pin, Pin) else Pin(**pin)
|
||||
for pin in (pins or [])
|
||||
]
|
||||
|
||||
super().__init__(host=host, port=port, password=password, pins=pins, name=name)
|
||||
self._pin_by_name = {pin['name']: pin for pin in self['pins'] if pin['name']}
|
||||
self._pin_by_number = {pin['number']: pin for pin in self['pins']}
|
||||
|
||||
def get_pin(self, pin) -> int:
|
||||
try:
|
||||
return int(pin)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
assert pin in self._pin_by_name, 'No such PIN configured: {}'.format(pin)
|
||||
return self._pin_by_name[pin]['number']
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
|
@ -199,6 +199,7 @@ def set_thread_name(name):
|
|||
except ImportError:
|
||||
logger.debug('Unable to set thread name: prctl module is missing')
|
||||
|
||||
|
||||
def find_bins_in_path(bin_name):
|
||||
return [os.path.join(p, bin_name)
|
||||
for p in os.environ.get('PATH', '').split(':')
|
||||
|
@ -261,9 +262,25 @@ def get_mime_type(resource):
|
|||
if mime:
|
||||
return mime.mime_type
|
||||
|
||||
|
||||
def camel_case_to_snake_case(string):
|
||||
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', string)
|
||||
return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
|
||||
|
||||
|
||||
def grouper(n, iterable, fillvalue=None):
|
||||
"""
|
||||
Split an iterable in groups of max N elements.
|
||||
grouper(3, 'ABCDEFG', 'x') --> ABC DEF Gxx
|
||||
"""
|
||||
from itertools import zip_longest
|
||||
args = [iter(iterable)] * n
|
||||
|
||||
if fillvalue:
|
||||
return zip_longest(fillvalue=fillvalue, *args)
|
||||
|
||||
for chunk in zip_longest(*args):
|
||||
yield filter(None, chunk)
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
|
|
@ -224,3 +224,5 @@ croniter
|
|||
# Support for nmap integration
|
||||
# python-nmap
|
||||
|
||||
# Support for ESP8266/ESP32 Micropython integration
|
||||
# websocket-client
|
||||
|
|
2
setup.py
2
setup.py
|
@ -277,5 +277,7 @@ setup(
|
|||
'sys': ['py-cpuinfo', 'psutil'],
|
||||
# Support for nmap integration
|
||||
'nmap': ['python-nmap'],
|
||||
# Support for ESP8266/ESP32 Micropython integration
|
||||
'esp': ['websocket-client'],
|
||||
},
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue