diff --git a/adafruit_esp32spi/adafruit_esp32spi.py b/adafruit_esp32spi/adafruit_esp32spi.py index a72bbb9..96bc200 100644 --- a/adafruit_esp32spi/adafruit_esp32spi.py +++ b/adafruit_esp32spi/adafruit_esp32spi.py @@ -64,6 +64,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) @@ -622,6 +623,25 @@ 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 + params = [struct.pack('>H', port), self._socknum_ll[0], (conn_mode,)] + if ip: + params.insert(0, ip) + resp = self._send_command_get_response(_START_SERVER_TCP_CMD, params) + + if resp[0][0] != 1: + raise RuntimeError("Could not start server") + + def 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.""" diff --git a/adafruit_esp32spi/adafruit_esp32spi_requests.py b/adafruit_esp32spi/adafruit_esp32spi_requests.py index 3857061..994af94 100755 --- a/adafruit_esp32spi/adafruit_esp32spi_requests.py +++ b/adafruit_esp32spi/adafruit_esp32spi_requests.py @@ -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() @@ -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) diff --git a/adafruit_esp32spi/adafruit_esp32spi_socket.py b/adafruit_esp32spi/adafruit_esp32spi_socket.py index ccf5b4f..f5c288b 100644 --- a/adafruit_esp32spi/adafruit_esp32spi_socket.py +++ b/adafruit_esp32spi/adafruit_esp32spi_socket.py @@ -29,10 +29,12 @@ * Author(s): ladyada """ +# pylint: disable=no-name-in-module import time import gc from micropython import const +from adafruit_esp32spi import adafruit_esp32spi _the_interface = None # pylint: disable=invalid-name def set_interface(iface): @@ -42,6 +44,7 @@ def set_interface(iface): SOCK_STREAM = const(1) AF_INET = const(2) +NO_SOCKET_AVAIL = const(255) MAX_PACKET = const(4000) @@ -59,14 +62,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 @@ -90,7 +95,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: @@ -106,7 +111,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: @@ -122,7 +127,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)) @@ -148,6 +153,38 @@ 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) + result = status not in (adafruit_esp32spi.SOCKET_LISTEN, + adafruit_esp32spi.SOCKET_CLOSED, + adafruit_esp32spi.SOCKET_FIN_WAIT_1, + adafruit_esp32spi.SOCKET_FIN_WAIT_2, + adafruit_esp32spi.SOCKET_TIME_WAIT, + adafruit_esp32spi.SOCKET_SYN_SENT, + adafruit_esp32spi.SOCKET_SYN_RCVD, + adafruit_esp32spi.SOCKET_CLOSE_WAIT) + 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) diff --git a/adafruit_esp32spi/adafruit_esp32spi_wsgiserver.py b/adafruit_esp32spi/adafruit_esp32spi_wsgiserver.py new file mode 100644 index 0000000..0d538e8 --- /dev/null +++ b/adafruit_esp32spi/adafruit_esp32spi_wsgiserver.py @@ -0,0 +1,218 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Matt Costi 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_wsgiserver` +================================================================================ + +A simple WSGI (Web Server Gateway Interface) server that interfaces with the ESP32 over SPI. +Opens a specified port on the ESP32 to listen for incoming HTTP Requests and +Accepts an Application object that must be callable, which gets called +whenever a new HTTP Request has been received. + +The Application MUST accept 2 ordered parameters: + 1. environ object (incoming request data) + 2. start_response function. Must be called before the Application + callable returns, in order to set the response status and headers. + +The Application MUST return a single string in a list, +which is the response data + +Requires update_poll being called in the applications main event loop. + +For more details about Python WSGI see: +https://www.python.org/dev/peps/pep-0333/ + +* Author(s): Matt Costi +""" +# pylint: disable=no-name-in-module + +import io +import gc +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) + +# pylint: disable=invalid-name +class WSGIServer: + """ + A simple server that implements the WSGI interface + """ + + def __init__(self, port=80, debug=False, application=None): + self.application = application + 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._response_status = None + self._response_headers = [] + + def start(self): + """ + starts the server and begins listening for incoming connections. + Call update_poll in the main loop for the application callable to be + invoked on receiving an incoming request. + """ + 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 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, + the application callable will be invoked. + """ + self.client_available() + if (self._client_sock and self._client_sock.available()): + environ = self._get_environ(self._client_sock) + result = self.application(environ, self._start_response) + self.finish_response(result) + + def finish_response(self, result): + """ + Called after the application callbile returns result data to respond with. + Creates the HTTP Response payload from the response_headers and results data, + and sends it back to client. + + :param string result: the data string to send back in the response to the client. + """ + try: + response = "HTTP/1.1 {0}\r\n".format(self._response_status) + for header in self._response_headers: + response += "{0}: {1}\r\n".format(*header) + response += "\r\n" + self._client_sock.write(response.encode("utf-8")) + for data in result: + if isinstance(data, bytes): + self._client_sock.write(data) + else: + self._client_sock.write(data.encode("utf-8")) + gc.collect() + finally: + print("closing") + self._client_sock.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 > 2: + 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 > 2: + 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 > 2: + print("client sock num is: ", sock.socknum) + self._client_sock = sock + return self._client_sock + + return None + + def _start_response(self, status, response_headers): + """ + The application callable will be given this method as the second param + This is to be called before the application callable returns, to signify + the response can be started with the given status and headers. + + :param string status: a status string including the code and reason. ex: "200 OK" + :param list response_headers: a list of tuples to represent the headers. + ex ("header-name", "header value") + """ + self._response_status = status + self._response_headers = [("Server", "esp32WSGIServer")] + response_headers + + def _get_environ(self, client): + """ + The application callable will be given the resulting environ dictionary. + It contains metadata about the incoming request and the request body ("wsgi.input") + + :param Socket client: socket to read the request from + """ + env = {} + line = str(client.readline(), "utf-8") + (method, path, ver) = line.rstrip("\r\n").split(None, 2) + + env["wsgi.version"] = (1, 0) + env["wsgi.url_scheme"] = "http" + env["wsgi.multithread"] = False + env["wsgi.multiprocess"] = False + env["wsgi.run_once"] = False + + env["REQUEST_METHOD"] = method + env["SCRIPT_NAME"] = "" + env["SERVER_NAME"] = _the_interface.pretty_ip(_the_interface.ip_address) + env["SERVER_PROTOCOL"] = ver + env["SERVER_PORT"] = self.port + if path.find("?") >= 0: + env["PATH_INFO"] = path.split("?")[0] + env["QUERY_STRING"] = path.split("?")[1] + else: + env["PATH_INFO"] = path + + headers = parse_headers(client) + if "content-type" in headers: + env["CONTENT_TYPE"] = headers.get("content-type") + if "content-length" in headers: + env["CONTENT_LENGTH"] = headers.get("content-length") + body = client.read(int(env["CONTENT_LENGTH"])) + env["wsgi.input"] = io.StringIO(body) + else: + body = client.read() + env["wsgi.input"] = io.StringIO(body) + for name, value in headers.items(): + key = "HTTP_" + name.replace('-', '_').upper() + if key in env: + value = "{0},{1}".format(env[key], value) + env[key] = value + + return env diff --git a/examples/server/esp32spi_wsgiserver.py b/examples/server/esp32spi_wsgiserver.py new file mode 100644 index 0000000..c971300 --- /dev/null +++ b/examples/server/esp32spi_wsgiserver.py @@ -0,0 +1,205 @@ +import os +import board +import busio +from digitalio import DigitalInOut +import neopixel + +from adafruit_esp32spi import adafruit_esp32spi +import adafruit_esp32spi.adafruit_esp32spi_wifimanager as wifimanager +import adafruit_esp32spi.adafruit_esp32spi_wsgiserver as server + +# This example depends on the 'static' folder in the examples folder +# being copied to the root of the circuitpython filesystem. +# This is where our static assets like html, js, and css live. + +# Get wifi details and more from a secrets.py file +try: + from secrets import secrets +except ImportError: + print("WiFi secrets are kept in secrets.py, please add them there!") + raise + +try: + import json as json_module +except ImportError: + import ujson as json_module + +print("ESP32 SPI simple web server test!") + +# If you are using a board with pre-defined ESP32 Pins: +esp32_cs = DigitalInOut(board.ESP_CS) +esp32_ready = DigitalInOut(board.ESP_BUSY) +esp32_reset = DigitalInOut(board.ESP_RESET) + +# If you have an externally connected ESP32: +# esp32_cs = DigitalInOut(board.D9) +# esp32_ready = DigitalInOut(board.D10) +# esp32_reset = DigitalInOut(board.D5) + +spi = busio.SPI(board.SCK, board.MOSI, board.MISO) +esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset) # pylint: disable=line-too-long + +"""Use below for Most Boards""" +status_light = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2) # Uncomment for Most Boards +"""Uncomment below for ItsyBitsy M4""" +# import adafruit_dotstar as dotstar +# status_light = dotstar.DotStar(board.APA102_SCK, board.APA102_MOSI, 1, brightness=1) + +## Connect to wifi with secrets +wifi = wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light) +wifi.connect() + +class SimpleWSGIApplication: + """ + An example of a simple WSGI Application that supports + basic route handling and static asset file serving for common file types + """ + + INDEX = "/index.html" + CHUNK_SIZE = 8912 # max number of bytes to read at once when reading files + + def __init__(self, static_dir=None, debug=False): + self._debug = debug + self._listeners = {} + self._start_response = None + self._static = static_dir + if self._static: + self._static_files = ["/" + file for file in os.listdir(self._static)] + + def __call__(self, environ, start_response): + """ + Called whenever the server gets a request. + The environ dict has details about the request per wsgi specification. + Call start_response with the response status string and headers as a list of tuples. + Return a single item list with the item being your response data string. + """ + if self._debug: + self._log_environ(environ) + + self._start_response = start_response + status = "" + headers = [] + resp_data = [] + + key = self._get_listener_key(environ["REQUEST_METHOD"].lower(), environ["PATH_INFO"]) + if key in self._listeners: + status, headers, resp_data = self._listeners[key](environ) + if environ["REQUEST_METHOD"].lower() == "get" and self._static: + path = environ["PATH_INFO"] + if path in self._static_files: + status, headers, resp_data = self.serve_file(path, directory=self._static) + elif path == "/" and self.INDEX in self._static_files: + status, headers, resp_data = self.serve_file(self.INDEX, directory=self._static) + + self._start_response(status, headers) + return resp_data + + 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 environ) + request_handler should return a tuple in the shape of: + (status, header_list, data_iterable) + + :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 serve_file(self, file_path, directory=None): + status = "200 OK" + headers = [("Content-Type", self._get_content_type(file_path))] + + full_path = file_path if not directory else directory + file_path + def resp_iter(): + with open(full_path, 'rb') as file: + while True: + chunk = file.read(self.CHUNK_SIZE) + if chunk: + yield chunk + else: + break + + return (status, headers, resp_iter()) + + def _log_environ(self, environ): # pylint: disable=no-self-use + print("environ map:") + for name, value in environ.items(): + print(name, value) + + 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 "text/html" + if ext == "js": + return "application/javascript" + if ext == "css": + return "text/css" + if ext in ("jpg", "jpeg"): + return "image/jpeg" + if ext == "png": + return "image/png" + return "text/plain" + +# Our HTTP Request handlers +def led_on(environ): # pylint: disable=unused-argument + print("led on!") + status_light.fill((0, 0, 100)) + return web_app.serve_file("static/index.html") + +def led_off(environ): # pylint: disable=unused-argument + print("led off!") + status_light.fill(0) + return web_app.serve_file("static/index.html") + +def led_color(environ): # pylint: disable=unused-argument + json = json_module.loads(environ["wsgi.input"].getvalue()) + print(json) + rgb_tuple = (json.get("r"), json.get("g"), json.get("b")) + status_light.fill(rgb_tuple) + return ("200 OK", [], []) + +# Here we create our application, setting the static directory location +# and registering the above request_handlers for specific HTTP requests +# we want to listen and respond to. +static = "/static" +try: + static_files = os.listdir(static) + if "index.html" not in static_files: + raise RuntimeError(""" + This example depends on an index.html, but it isn't present. + Please add it to the {0} directory""".format(static)) +except (OSError) as e: + raise RuntimeError(""" + This example depends on a static asset directory. + Please create one named {0} in the root of the device filesystem.""".format(static)) + +web_app = SimpleWSGIApplication(static_dir=static) +web_app.on("GET", "/led_on", led_on) +web_app.on("GET", "/led_off", led_off) +web_app.on("POST", "/ajax/ledcolor", led_color) + +# Here we setup our server, passing in our web_app as the application +server.set_interface(esp) +wsgiServer = server.WSGIServer(80, application=web_app) + +print("open this IP in your browser: ", esp.pretty_ip(esp.ip_address)) + +# Start the server +wsgiServer.start() +while True: + # Our main loop where we have the server poll for incoming requests + try: + wsgiServer.update_poll() + # Could do any other background tasks here, like reading sensors + except (ValueError, RuntimeError) as e: + print("Failed to update server, restarting ESP32\n", e) + wifi.reset() + continue diff --git a/examples/server/static/index.html b/examples/server/static/index.html new file mode 100755 index 0000000..460a37b --- /dev/null +++ b/examples/server/static/index.html @@ -0,0 +1,14 @@ + + +
+ + + + + + + +