diff --git a/adafruit_wsgi/esp32spi_wsgiserver.py b/adafruit_wsgi/esp32spi_wsgiserver.py
new file mode 100644
index 0000000..d43a27a
--- /dev/null
+++ b/adafruit_wsgi/esp32spi_wsgiserver.py
@@ -0,0 +1,255 @@
+# SPDX-FileCopyrightText: Copyright (c) 2019 Matt Costi for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+"""
+`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, protected-access
+
+import io
+import gc
+import time
+
+from micropython import const
+import adafruit_esp32spi.adafruit_esp32spi_socket as socket
+
+_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)
+
+
+def parse_headers(client):
+ """
+ Parses the header portion of an HTTP request from the socket.
+ Expects first line of HTTP request to have been read already.
+ """
+ headers = {}
+ while True:
+ # line = str(client.readline(), "utf-8")
+ line = str(socket_readline(client), "utf-8")
+ if not line:
+ break
+ title, content = line.split(":", 1)
+ headers[title.strip().lower()] = content.strip()
+ return headers
+
+
+def socket_readline(_socket, eol=b"\r\n"):
+ """Attempt to return as many bytes as we can up to but not including
+ end-of-line character (default is '\\r\\n')"""
+
+ # print("Socket readline")
+ stamp = time.monotonic()
+ while eol not in _socket._buffer:
+ # there's no line already in there, read some more
+ avail = _socket._available()
+ if avail:
+ _socket._buffer += _the_interface.socket_read(_socket._socknum, avail)
+ elif _socket._timeout > 0 and time.monotonic() - stamp > _socket._timeout:
+ _socket.close() # Make sure to close socket so that we don't exhaust sockets.
+ raise OSError("Didn't receive full response, failing out")
+ firstline, _socket._buffer = _socket._buffer.split(eol, 1)
+ gc.collect()
+ return firstline
+
+
+# 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(
+ "Server status: ",
+ _the_interface.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 or "500 ISE")
+ for header in self._response_headers:
+ response += "{0}: {1}\r\n".format(*header)
+ response += "\r\n"
+ self._client_sock.send(response.encode("utf-8"))
+ if isinstance(result, bytes): # send whole response if possible (see #174)
+ self._client_sock.send(result)
+ elif isinstance(result, str):
+ self._client_sock.send(result.encode("utf-8"))
+ else: # fall back to sending byte-by-byte
+ for data in result:
+ if isinstance(data, bytes):
+ self._client_sock.send(data)
+ else:
+ self._client_sock.send(data.encode("utf-8"))
+ gc.collect()
+ finally:
+ if self._debug > 2:
+ 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"),
+ ("Connection", "close"),
+ ] + 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")
+ line = str(socket_readline(client), "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.recv(int(env["CONTENT_LENGTH"]))
+ env["wsgi.input"] = io.StringIO(body)
+ else:
+ body = client.recv(0)
+ 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/static/index.html b/examples/static/index.html
new file mode 100755
index 0000000..df08ec7
--- /dev/null
+++ b/examples/static/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+ LED color picker demo!
+
+
+
diff --git a/examples/static/led_color_picker_example.js b/examples/static/led_color_picker_example.js
new file mode 100755
index 0000000..810ca44
--- /dev/null
+++ b/examples/static/led_color_picker_example.js
@@ -0,0 +1,129 @@
+// SPDX-FileCopyrightText: 2019 ladyada for Adafruit Industries
+//
+// SPDX-License-Identifier: MIT
+
+let canvas = document.getElementById('colorPicker');
+let ctx = canvas.getContext("2d");
+ctx.width = 300;
+ctx.height = 300;
+
+function drawColorPicker() {
+ /**
+ * Color picker inspired by:
+ * https://medium.com/@bantic/hand-coding-a-color-wheel-with-canvas-78256c9d7d43
+ */
+ let radius = 150;
+ let image = ctx.createImageData(2*radius, 2*radius);
+ let data = image.data;
+
+ for (let x = -radius; x < radius; x++) {
+ for (let y = -radius; y < radius; y++) {
+
+ let [r, phi] = xy2polar(x, y);
+
+ if (r > radius) {
+ // skip all (x,y) coordinates that are outside of the circle
+ continue;
+ }
+
+ let deg = rad2deg(phi);
+
+ // Figure out the starting index of this pixel in the image data array.
+ let rowLength = 2*radius;
+ let adjustedX = x + radius; // convert x from [-50, 50] to [0, 100] (the coordinates of the image data array)
+ let adjustedY = y + radius; // convert y from [-50, 50] to [0, 100] (the coordinates of the image data array)
+ let pixelWidth = 4; // each pixel requires 4 slots in the data array
+ let index = (adjustedX + (adjustedY * rowLength)) * pixelWidth;
+
+ let hue = deg;
+ let saturation = r / radius;
+ let value = 1.0;
+
+ let [red, green, blue] = hsv2rgb(hue, saturation, value);
+ let alpha = 255;
+
+ data[index] = red;
+ data[index+1] = green;
+ data[index+2] = blue;
+ data[index+3] = alpha;
+ }
+ }
+
+ ctx.putImageData(image, 0, 0);
+}
+
+function xy2polar(x, y) {
+ let r = Math.sqrt(x*x + y*y);
+ let phi = Math.atan2(y, x);
+ return [r, phi];
+}
+
+// rad in [-π, π] range
+// return degree in [0, 360] range
+function rad2deg(rad) {
+ return ((rad + Math.PI) / (2 * Math.PI)) * 360;
+}
+
+ // hue in range [0, 360]
+ // saturation, value in range [0,1]
+ // return [r,g,b] each in range [0,255]
+ // See: https://en.wikipedia.org/wiki/HSL_and_HSV#From_HSV
+function hsv2rgb(hue, saturation, value) {
+ let chroma = value * saturation;
+ let hue1 = hue / 60;
+ let x = chroma * (1- Math.abs((hue1 % 2) - 1));
+ let r1, g1, b1;
+ if (hue1 >= 0 && hue1 <= 1) {
+ ([r1, g1, b1] = [chroma, x, 0]);
+ } else if (hue1 >= 1 && hue1 <= 2) {
+ ([r1, g1, b1] = [x, chroma, 0]);
+ } else if (hue1 >= 2 && hue1 <= 3) {
+ ([r1, g1, b1] = [0, chroma, x]);
+ } else if (hue1 >= 3 && hue1 <= 4) {
+ ([r1, g1, b1] = [0, x, chroma]);
+ } else if (hue1 >= 4 && hue1 <= 5) {
+ ([r1, g1, b1] = [x, 0, chroma]);
+ } else if (hue1 >= 5 && hue1 <= 6) {
+ ([r1, g1, b1] = [chroma, 0, x]);
+ }
+
+ let m = value - chroma;
+ let [r,g,b] = [r1+m, g1+m, b1+m];
+
+ // Change r,g,b values from [0,1] to [0,255]
+ return [255*r,255*g,255*b];
+}
+
+function onColorPick(event) {
+ coords = getCursorPosition(canvas, event)
+ imageData = ctx.getImageData(coords[0],coords[1],1,1)
+ rgbObject = {
+ r: imageData.data[0],
+ g: imageData.data[1],
+ b: imageData.data[2]
+ }
+ console.log(`r: ${rgbObject.r} g: ${rgbObject.g} b: ${rgbObject.b}`);
+ data = JSON.stringify(rgbObject);
+ window.fetch("/ajax/ledcolor", {
+ method: "POST",
+ body: data,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8',
+ },
+ }).then(response => {
+ console.log("sucess!: " + response)
+ }, error => {
+ console.log("error!: " + error)
+ })
+}
+
+function getCursorPosition(canvas, event) {
+ const rect = canvas.getBoundingClientRect()
+ const x = event.clientX - rect.left
+ const y = event.clientY - rect.top
+ console.log("x: " + x + " y: " + y)
+ return [x,y]
+}
+
+drawColorPicker();
+canvas.addEventListener('mousedown', onColorPick);
diff --git a/examples/wsgi_simpletest.py b/examples/wsgi_simpletest.py
index 9047552..738b206 100644
--- a/examples/wsgi_simpletest.py
+++ b/examples/wsgi_simpletest.py
@@ -8,7 +8,7 @@
from adafruit_esp32spi import adafruit_esp32spi
import adafruit_esp32spi.adafruit_esp32spi_wifimanager as wifimanager
-import adafruit_esp32spi.adafruit_esp32spi_wsgiserver as server
+import adafruit_wsgi.esp32spi_wsgiserver as server
from adafruit_wsgi.wsgi_app import WSGIApp
# Get wifi details and more from a secrets.py file
diff --git a/examples/wsgi_static_files_server.py b/examples/wsgi_static_files_server.py
new file mode 100755
index 0000000..eacd70b
--- /dev/null
+++ b/examples/wsgi_static_files_server.py
@@ -0,0 +1,246 @@
+# SPDX-FileCopyrightText: 2019 ladyada for Adafruit Industries
+#
+# SPDX-License-Identifier: MIT
+
+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_wsgi.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
+
+print("MAC addr:", [hex(i) for i in esp.MAC_address])
+print("MAC addr actual:", [hex(i) for i in esp.MAC_address_actual])
+
+# 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)
+
+## If you want to connect to wifi with secrets:
+wifi = wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light)
+wifi.connect()
+
+## If you want to create a WIFI hotspot to connect to with secrets:
+# secrets = {"ssid": "My ESP32 AP!", "password": "supersecret"}
+# wifi = wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light)
+# wifi.create_ap()
+
+## To you want to create an un-protected WIFI hotspot to connect to with secrets:"
+# secrets = {"ssid": "My ESP32 AP!"}
+# wifi = wifimanager.ESPSPI_WiFiManager(esp, secrets, status_light)
+# wifi.create_ap()
+
+
+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
+ )
+ ) from e
+
+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 OSError as e:
+ print("Failed to update server, restarting ESP32\n", e)
+ wifi.reset()
+ continue