diff --git a/adafruit_esp32spi/adafruit_esp32spi.py b/adafruit_esp32spi/adafruit_esp32spi.py index fae4cc6..7d82c6b 100644 --- a/adafruit_esp32spi/adafruit_esp32spi.py +++ b/adafruit_esp32spi/adafruit_esp32spi.py @@ -780,7 +780,7 @@ def socket_available(self, socket_num): return reply def socket_read(self, socket_num, size): - """Read up to 'size' bytes from the socket number. Returns a bytearray""" + """Read up to 'size' bytes from the socket number. Returns a bytes""" if self._debug: print( "Reading %d bytes from ESP socket with status %d" diff --git a/adafruit_esp32spi/adafruit_esp32spi_socket.py b/adafruit_esp32spi/adafruit_esp32spi_socket.py index c35a94b..d08f090 100644 --- a/adafruit_esp32spi/adafruit_esp32spi_socket.py +++ b/adafruit_esp32spi/adafruit_esp32spi_socket.py @@ -86,144 +86,65 @@ def send(self, data): # pylint: disable=no-self-use _the_interface.socket_write(self._socknum, data, conn_mode=conntype) gc.collect() - def write(self, data): - """Sends data to the socket. - NOTE: This method is deprecated and will be removed. - """ - self.send(data) - - def readline(self, 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 self._buffer: - # there's no line already in there, read some more - avail = self.available() - if avail: - self._buffer += _the_interface.socket_read(self._socknum, avail) - elif self._timeout > 0 and time.monotonic() - stamp > self._timeout: - self.close() # Make sure to close socket so that we don't exhaust sockets. - raise RuntimeError("Didn't receive full response, failing out") - firstline, self._buffer = self._buffer.split(eol, 1) - gc.collect() - return firstline - - def recv(self, bufsize=0): + def recv(self, bufsize: int): """Reads some bytes from the connected remote address. Will only return an empty string after the configured timeout. :param int bufsize: maximum number of bytes to receive """ - # print("Socket read", bufsize) - if bufsize == 0: # read as much as we can at the moment - while True: - avail = self.available() - if avail: - self._buffer += _the_interface.socket_read(self._socknum, avail) - else: - break - gc.collect() - ret = self._buffer - self._buffer = b"" - gc.collect() - return ret - stamp = time.monotonic() - - to_read = bufsize - len(self._buffer) - received = [] - while to_read > 0: - # print("Bytes to read:", to_read) - avail = self.available() - if avail: - stamp = time.monotonic() - recv = _the_interface.socket_read(self._socknum, min(to_read, avail)) - received.append(recv) - to_read -= len(recv) - gc.collect() - elif received: - # We've received some bytes but no more are available. So return - # what we have. - break - if self._timeout > 0 and time.monotonic() - stamp > self._timeout: - break - # print(received) - self._buffer += b"".join(received) - - ret = None - if len(self._buffer) == bufsize: - ret = self._buffer - self._buffer = b"" - else: - ret = self._buffer[:bufsize] - self._buffer = self._buffer[bufsize:] - gc.collect() - return ret + buf = bytearray(bufsize) + self.recv_into(buf, bufsize) - def recv_into(self, buffer, nbytes=0): - """Read some bytes from the connected remote address into a given buffer + def recv_into(self, buffer, nbytes: int = 0): + """Read bytes from the connected remote address into a given buffer. - :param bytearray buffer: The buffer to read into - :param int nbytes: (Optional) Number of bytes to receive default is 0, - which will receive as many bytes as possible before filling the + :param bytearray buffer: the buffer to read into + :param int nbytes: maximum number of bytes to receive; if 0, + receive as many bytes as possible before filling the buffer or timing out """ - if not 0 <= nbytes <= len(buffer): - raise ValueError( - "Can only read number of bytes between 0 and length of supplied buffer" - ) - - stamp = time.monotonic() - to_read = len(buffer) - limit = 0 if nbytes == 0 else to_read - nbytes - received = [] - while to_read > limit: - # print("Bytes to read:", to_read) - avail = self.available() - if avail: - stamp = time.monotonic() - recv = _the_interface.socket_read(self._socknum, min(to_read, avail)) - received.append(recv) - start = len(buffer) - to_read - to_read -= len(recv) - end = len(buffer) - to_read - buffer[start:end] = bytearray(recv) - gc.collect() - elif received: - # We've received some bytes but no more are available. So return - # what we have. + raise ValueError("nbytes must be 0 to len(buffer)") + + last_read_time = time.monotonic() + num_to_read = len(buffer) if nbytes == 0 else nbytes + num_read = 0 + while num_read < num_to_read: + num_avail = self._available() + if num_avail > 0: + last_read_time = time.monotonic() + bytes_read = _the_interface.socket_read( + self._socknum, min(num_to_read, num_avail) + ) + buffer[num_read : num_read + len(bytes_read)] = bytes_read + num_read += len(bytes_read) + elif num_read > 0: + # We got a message, but there are no more bytes to read, so we can stop. break - if self._timeout > 0 and time.monotonic() - stamp > self._timeout: + # No bytes yet, or more byte requested. + if self._timeout > 0 and time.monotonic() - last_read_time > self._timeout: break - gc.collect() - return len(buffer) - to_read - - def read(self, size=0): - """Read up to 'size' bytes from the socket, this may be buffered internally! - If 'size' isnt specified, return everything in the buffer. - NOTE: This method is deprecated and will be removed. - """ - return self.recv(size) + return num_read def settimeout(self, value): - """Set the read timeout for sockets, if value is 0 it will block""" + """Set the read timeout for sockets. + If value is 0 socket reads will block until a message is available. + """ self._timeout = value - def available(self): + 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: + if self._socknum != NO_SOCKET_AVAIL: return min(_the_interface.socket_available(self._socknum), MAX_PACKET) return 0 - def connected(self): + def _connected(self): """Whether or not we are connected to the socket""" - if self.socknum == NO_SOCKET_AVAIL: + if self._socknum == NO_SOCKET_AVAIL: return False - if self.available(): + if self._available(): return True - status = _the_interface.socket_status(self.socknum) + status = _the_interface.socket_status(self._socknum) result = status not in ( adafruit_esp32spi.SOCKET_LISTEN, adafruit_esp32spi.SOCKET_CLOSED, @@ -239,11 +160,6 @@ def connected(self): 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 deleted file mode 100644 index 956a36c..0000000 --- a/adafruit_esp32spi/adafruit_esp32spi_wsgiserver.py +++ /dev/null @@ -1,227 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2019 Matt Costi for Adafruit Industries -# -# SPDX-License-Identifier: MIT - -""" -`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 - -_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") - if not line: - break - title, content = line.split(":", 1) - headers[title.strip().lower()] = content.strip() - return headers - - -# 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")) - 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") - (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 deleted file mode 100755 index d9f0979..0000000 --- a/examples/server/esp32spi_wsgiserver.py +++ /dev/null @@ -1,246 +0,0 @@ -# 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_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 - -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 (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 deleted file mode 100755 index df08ec7..0000000 --- a/examples/server/static/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - -
- - - -