Skip to content

Add Server creation and management support #59

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Jul 24, 2019
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5b28118
separate server code from AP code
mscosti Jul 13, 2019
5e09b7b
Add server class to handle server start and get connected clients
mscosti Jul 13, 2019
faf031a
add simple request method handling
mscosti Jul 14, 2019
8c4035d
py linting
mscosti Jul 14, 2019
b8fd02e
more linting
mscosti Jul 14, 2019
5bd6c86
add in support for automatically serving files from a specified direc…
mscosti Jul 14, 2019
83d1525
add in example static asset serving with ajax call LED color changing
mscosti Jul 14, 2019
f6d9415
fix spacing
mscosti Jul 14, 2019
d469ab9
untested first draft of simple WSGI server
mscosti Jul 20, 2019
94fce44
fix a couple wsgi server bugs
mscosti Jul 21, 2019
c429455
send response headers separately before body to support chunking
mscosti Jul 21, 2019
92d0c66
new example script for using WsgiServer
mscosti Jul 21, 2019
6af7675
Merge branch 'master' of https://github.com/adafruit/Adafruit_Circuit…
mscosti Jul 21, 2019
d1fe777
fix led_on to pass rgb tuple
mscosti Jul 21, 2019
842ac46
Remove server.py in favor of new wsgiserver.py
mscosti Jul 21, 2019
c2b7cb0
Address PR comments for adafruit_esp32spi.py
mscosti Jul 21, 2019
4f3d07f
fix file name. adafruite -> adafruit
mscosti Jul 21, 2019
fc97dac
fix linting of example file
mscosti Jul 21, 2019
41eab1c
PATH_INFO shouldn't contain query params
mscosti Jul 21, 2019
e6bfd36
remove wifimanager debug arg
mscosti Jul 22, 2019
473f850
fix PATH_INFO when there are query params
mscosti Jul 22, 2019
a93c099
Address PR comments
mscosti Jul 23, 2019
fd4e191
Use constants instead of magic numbers
mscosti Jul 23, 2019
6951f8c
fix import?
mscosti Jul 23, 2019
a5d59f8
fix linting
mscosti Jul 23, 2019
8dbeef0
move all server example assets into examples/server
mscosti Jul 23, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions adafruit_esp32spi/adafruit_esp32spi.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
# pylint: disable=bad-whitespace
_SET_NET_CMD = const(0x10)
_SET_PASSPHRASE_CMD = const(0x11)
_SET_AP_PASSPHRASE_CMD = const(0x19)
_SET_DEBUG_CMD = const(0x1A)

_GET_CONN_STATUS_CMD = const(0x20)
Expand All @@ -64,6 +65,7 @@
_GET_CURR_ENCT_CMD = const(0x26)

_SCAN_NETWORKS = const(0x27)
_START_SERVER_TCP_CMD = const(0x28)
_GET_SOCKET_CMD = const(0x3F)
_GET_STATE_TCP_CMD = const(0x29)
_DATA_SENT_TCP_CMD = const(0x2A)
Expand Down Expand Up @@ -622,6 +624,32 @@ def socket_close(self, socket_num):
if resp[0][0] != 1:
raise RuntimeError("Failed to close socket")

def start_server(self, port, socket_num, conn_mode=TCP_MODE, ip=None): # pylint: disable=invalid-name
"""Opens a server on the specified port, using the ESP32's internal reference number"""
if self._debug:
print("*** starting server")
self._socknum_ll[0][0] = socket_num
port_param = struct.pack('>H', port)
if ip: # use the 4 arg version
resp = self._send_command_get_response(_START_SERVER_TCP_CMD,
(ip,
port_param,
self._socknum_ll[0],
(conn_mode,)))
else: # use the 3 arg version
resp = self._send_command_get_response(_START_SERVER_TCP_CMD,
(port_param,
self._socknum_ll[0],
(conn_mode,)))
if resp[0][0] != 1:
raise RuntimeError("Could not start server")

def get_server_state(self, socket_num):
"""Get the state of the ESP32's internal reference server socket number"""
self._socknum_ll[0][0] = socket_num
resp = self._send_command_get_response(_GET_STATE_TCP_CMD, self._socknum_ll)
return resp[0][0]

def set_esp_debug(self, enabled):
"""Enable/disable debug mode on the ESP32. Debug messages will be
written to the ESP32's UART."""
Expand Down
43 changes: 26 additions & 17 deletions adafruit_esp32spi/adafruit_esp32spi_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,23 +204,11 @@ def request(method, url, data=None, json=None, headers=None, stream=False, timeo
reason = ""
if len(line) > 2:
reason = line[2].rstrip()
while True:
line = sock.readline()
if not line or line == b"\r\n":
break

#print("**line: ", line)
title, content = line.split(b': ', 1)
if title and content:
title = str(title.lower(), 'utf-8')
content = str(content, 'utf-8')
resp.headers[title] = content

if line.startswith(b"Transfer-Encoding:"):
if b"chunked" in line:
raise ValueError("Unsupported " + line)
elif line.startswith(b"Location:") and not 200 <= status <= 299:
raise NotImplementedError("Redirects not yet supported")
resp.headers = parse_headers(sock)
if "chunked" in resp.headers.get("transfer-encoding"):
raise ValueError("Unsupported " + line)
elif resp.headers.get("location") and not 200 <= status <= 299:
raise NotImplementedError("Redirects not yet supported")

except:
sock.close()
Expand All @@ -232,6 +220,27 @@ def request(method, url, data=None, json=None, headers=None, stream=False, timeo
# pylint: enable=too-many-branches, too-many-statements, unused-argument
# pylint: enable=too-many-arguments, too-many-locals

def parse_headers(sock):
"""
Parses the header portion of an HTTP request/response from the socket.
Expects first line of HTTP request/response to have been read already
return: header dictionary
rtype: Dict
"""
headers = {}
while True:
line = sock.readline()
if not line or line == b"\r\n":
break

#print("**line: ", line)
title, content = line.split(b': ', 1)
if title and content:
title = str(title.lower(), 'utf-8')
content = str(content, 'utf-8')
headers[title] = content
return headers

def head(url, **kw):
"""Send HTTP HEAD request"""
return request("HEAD", url, **kw)
Expand Down
193 changes: 193 additions & 0 deletions adafruit_esp32spi/adafruit_esp32spi_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# The MIT License (MIT)
#
# Copyright (c) 2019 ladyada for Adafruit Industries
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.

"""
`adafruit_esp32spi_server`
================================================================================

TODO: better description?
Server management lib to make handling and responding to incoming requests much easier

* Author(s): Matt Costi
"""
# pylint: disable=no-name-in-module

import os
from micropython import const
import adafruit_esp32spi.adafruit_esp32spi_socket as socket
from adafruit_esp32spi.adafruit_esp32spi_requests import parse_headers

_the_interface = None # pylint: disable=invalid-name
def set_interface(iface):
"""Helper to set the global internet interface"""
global _the_interface # pylint: disable=global-statement, invalid-name
_the_interface = iface
socket.set_interface(iface)

NO_SOCK_AVAIL = const(255)
INDEX_HTML = "/index.html"


# pylint: disable=unused-argument, redefined-builtin, invalid-name
class server:
""" TODO: class docs """
def __init__(self, port=80, debug=False):
self.port = port
self._server_sock = socket.socket(socknum=NO_SOCK_AVAIL)
self._client_sock = socket.socket(socknum=NO_SOCK_AVAIL)
self._debug = debug
self._listeners = {}
self._static_dir = None
self._static_files = []


def start(self):
""" start the server """
self._server_sock = socket.socket()
_the_interface.start_server(self.port, self._server_sock.socknum)
if self._debug:
ip = _the_interface.pretty_ip(_the_interface.ip_address)
print("Server available at {0}:{1}".format(ip, self.port))
print("Sever status: ", _the_interface.get_server_state(self._server_sock.socknum))

def on(self, method, path, request_handler):
"""
Register a Request Handler for a particular HTTP method and path.
request_handler will be called whenever a matching HTTP request is received.

request_handler should accept the following args:
(Dict headers, bytes body, Socket client)

:param str method: the method of the HTTP request
:param str path: the path of the HTTP request
:param func request_handler: the function to call
"""
self._listeners[self._get_listener_key(method, path)] = request_handler

def set_static_dir(self, directory_path):
"""
allows for setting a directory of static files that will be auto-served
when that file is GET requested at'/<fileName.extension>'
index.html will also be made available at root path '/'

Note: does not support serving files in child folders at this time
"""
self._static_dir = directory_path
self._static_files = ["/" + file for file in os.listdir(self._static_dir)]
print(self._static_files)

def serve_file(self, file_path, dir=None):
"""
writes a file from the file system as a response to the client.

:param string file_path: path to the image to write to client.
if dir is not present, it is treated as an absolute path
:param string dir: path to directory that file is located in (optional)
"""
self._client_sock.write(b"HTTP/1.1 200 OK\r\n")
self._client_sock.write(b"Content-Type:" + self._get_content_type(file_path) + b"\r\n")
self._client_sock.write(b"\r\n")
full_path = file_path if not dir else dir + file_path
with open(full_path, 'rb') as fp:
for line in fp:
self._client_sock.write(line)
self._client_sock.write(b"\r\n")
self._client_sock.close()

def update_poll(self):
"""
Call this method inside your main event loop to get the server
check for new incoming client requests. When a request comes in for
which a request handler has been registered with 'on' method, that
request handler will be invoked.

Unrecognized requests will be automatically be responded to with a 404.
"""
client = self.client_available()
if (client and client.available()):
line = client.readline()
line = line.split(None, 2)
method = str(line[0], "utf-8")
path = str(line[1], "utf-8")
key = self._get_listener_key(method, path)
if key in self._listeners:
headers = parse_headers(client)
body = client.read()
self._listeners[key](headers, body, client)
elif method.lower() == "get":
client.read()
if path in self._static_files:
self.serve_file(path, dir=self._static_dir)
elif path == "/" and INDEX_HTML in self._static_files:
self.serve_file(INDEX_HTML, dir=self._static_dir)
else:
# TODO: support optional custom 404 handler?
self._client_sock.write(b"HTTP/1.1 404 NotFound\r\n")
client.close()


def client_available(self):
"""
returns a client socket connection if available.
Otherwise, returns None
:return: the client
:rtype: Socket
"""
sock = None
if self._server_sock.socknum != NO_SOCK_AVAIL:
if self._client_sock.socknum != NO_SOCK_AVAIL:
# check previous received client socket
if self._debug:
print("checking if last client sock still valid")
if self._client_sock.connected() and self._client_sock.available():
sock = self._client_sock
if not sock:
# check for new client sock
if self._debug:
print("checking for new client sock")
client_sock_num = _the_interface.socket_available(self._server_sock.socknum)
sock = socket.socket(socknum=client_sock_num)
else:
print("Server has not been started, cannot check for clients!")

if sock and sock.socknum != NO_SOCK_AVAIL:
if self._debug:
print("client sock num is: ", sock.socknum)
self._client_sock = sock
return self._client_sock

return None

def _get_listener_key(self, method, path): # pylint: disable=no-self-use
return "{0}|{1}".format(method.lower(), path)


def _get_content_type(self, file): # pylint: disable=no-self-use
ext = file.split('.')[-1]
if ext in ("html", "htm"):
return b"text/html"
if ext == "js":
return b"application/javascript"
if ext == "css":
return b"text/css"
# TODO: test adding in support for image types as well
return b"text/plain"
46 changes: 41 additions & 5 deletions adafruit_esp32spi/adafruit_esp32spi_socket.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ def set_interface(iface):

SOCK_STREAM = const(1)
AF_INET = const(2)
NO_SOCKET_AVAIL = const(255)

MAX_PACKET = const(4000)

Expand All @@ -59,14 +60,16 @@ def getaddrinfo(host, port, family=0, socktype=0, proto=0, flags=0):
class socket:
"""A simplified implementation of the Python 'socket' class, for connecting
through an interface to a remote device"""
def __init__(self, family=AF_INET, type=SOCK_STREAM, proto=0, fileno=None):
# pylint: disable=too-many-arguments
def __init__(self, family=AF_INET, type=SOCK_STREAM, proto=0, fileno=None, socknum=None):
if family != AF_INET:
raise RuntimeError("Only AF_INET family supported")
if type != SOCK_STREAM:
raise RuntimeError("Only SOCK_STREAM type supported")
self._buffer = b''
self._socknum = _the_interface.get_socket()
self._socknum = socknum if socknum else _the_interface.get_socket()
self.settimeout(0)
# pylint: enable=too-many-arguments

def connect(self, address, conntype=None):
"""Connect the socket to the 'address' (which can be 32bit packed IP or
Expand All @@ -90,7 +93,7 @@ def readline(self):
stamp = time.monotonic()
while b'\r\n' not in self._buffer:
# there's no line already in there, read some more
avail = min(_the_interface.socket_available(self._socknum), MAX_PACKET)
avail = self.available()
if avail:
self._buffer += _the_interface.socket_read(self._socknum, avail)
elif self._timeout > 0 and time.monotonic() - stamp > self._timeout:
Expand All @@ -106,7 +109,7 @@ def read(self, size=0):
#print("Socket read", size)
if size == 0: # read as much as we can at the moment
while True:
avail = min(_the_interface.socket_available(self._socknum), MAX_PACKET)
avail = self.available()
if avail:
self._buffer += _the_interface.socket_read(self._socknum, avail)
else:
Expand All @@ -122,7 +125,7 @@ def read(self, size=0):
received = []
while to_read > 0:
#print("Bytes to read:", to_read)
avail = min(_the_interface.socket_available(self._socknum), MAX_PACKET)
avail = self.available()
if avail:
stamp = time.monotonic()
recv = _the_interface.socket_read(self._socknum, min(to_read, avail))
Expand All @@ -148,6 +151,39 @@ def settimeout(self, value):
"""Set the read timeout for sockets, if value is 0 it will block"""
self._timeout = value

def available(self):
"""Returns how many bytes of data are available to be read (up to the MAX_PACKET length)"""
if self.socknum != NO_SOCKET_AVAIL:
return min(_the_interface.socket_available(self._socknum), MAX_PACKET)
return 0

def connected(self):
"""Whether or not we are connected to the socket"""
if self.socknum == NO_SOCKET_AVAIL:
return False
elif self.available():
return True
else:
status = _the_interface.socket_status(self.socknum)
# TODO: why is esp.<ConstantName> not defined? using magic numbers in mean time
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to use the same constants as defined in adafruit_esp32spi, but for some reason I wasn't able to get that to work. The constants weren't accessible off of the that module when running in CP (but my editor was showing them as available)

Any ideas? We obviously don't want the magic numbers here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which line are you referring to here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all of the numbers in status not in (magic numbers)

All of those are constants corresponding to ones defined in esp32spi.py https://github.com/adafruit/Adafruit_CircuitPython_ESP32SPI/blob/master/adafruit_esp32spi/adafruit_esp32spi.py#L101-L111

My initial attempt to use them wasn't working, I'm sure there is just something silly i'm missing with being able to re-use those constants here.

result = status not in (1,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for context, stole this logic from the arduino WiFiNINA lib: https://github.com/arduino-libraries/WiFiNINA/blob/master/src/WiFiClient.cpp#L221-L242

0,
5,
6,
10,
2,
3,
7)
if not result:
self.close()
self._socknum = NO_SOCKET_AVAIL
return result

@property
def socknum(self):
"""The socket number"""
return self._socknum

def close(self):
"""Close the socket, after reading whatever remains"""
_the_interface.socket_close(self._socknum)
Expand Down
Loading