diff --git a/README.rst b/README.rst index 5bf2f70..75e8727 100644 --- a/README.rst +++ b/README.rst @@ -102,6 +102,69 @@ wifitest.adafruit.com. print("Done!") +This example demonstrates a simple web server that allows setting the Neopixel color. + +.. code-block:: python + + import board + import busio + import digitalio + import neopixel + + from adafruit_wiznet5k.adafruit_wiznet5k import WIZNET5K + import adafruit_wiznet5k.adafruit_wiznet5k_wsgiserver as server + from adafruit_wsgi.wsgi_app import WSGIApp + + print("Wiznet5k Web Server Test") + + # Status LED + led = neopixel.NeoPixel(board.NEOPIXEL, 1) + led.brightness = 0.3 + led[0] = (0, 0, 255) + + # W5500 connections + cs = digitalio.DigitalInOut(board.D10) + spi_bus = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) + + # Initialize ethernet interface with DHCP and the MAC we have from the 24AA02E48 + eth = WIZNET5K(spi_bus, cs) + + # Here we create our application, registering the + # following functions to be called on specific HTTP GET requests routes + + web_app = WSGIApp() + + + @web_app.route("/led///") + def led_on(request, r, g, b): + print("led handler") + led.fill((int(r), int(g), int(b))) + return ("200 OK", [], ["led set!"]) + + @web_app.route("/") + def root(request): + print("root handler") + return ("200 OK", [], ["root document"]) + + @web_app.route("/large") + def large(request): + print("large handler") + return ("200 OK", [], ["*-.-" * 2000]) + + + # Here we setup our server, passing in our web_app as the application + server.set_interface(eth) + wsgiServer = server.WSGIServer(80, application=web_app) + + print("Open this IP in your browser: ", eth.pretty_ip(eth.ip_address)) + + # Start the server + wsgiServer.start() + while True: + # Our main loop where we have the server poll for incoming requests + wsgiServer.update_poll() + # Could do any other background tasks here, like reading sensors + Contributing ============ diff --git a/adafruit_wiznet5k/adafruit_wiznet5k.py b/adafruit_wiznet5k/adafruit_wiznet5k.py old mode 100755 new mode 100644 index d1bc5c2..ca5ae0c --- a/adafruit_wiznet5k/adafruit_wiznet5k.py +++ b/adafruit_wiznet5k/adafruit_wiznet5k.py @@ -3,6 +3,7 @@ # SPDX-FileCopyrightText: 2008 Bjoern Hartmann # SPDX-FileCopyrightText: 2018 Paul Stoffregen # SPDX-FileCopyrightText: 2020 Brent Rubell for Adafruit Industries +# SPDX-FileCopyrightText: 2021 Patrick Van Oosterwijck # # SPDX-License-Identifier: MIT @@ -12,7 +13,8 @@ Pure-Python interface for WIZNET 5k ethernet modules. -* Author(s): WIZnet, Arduino LLC, Bjoern Hartmann, Paul Stoffregen, Brent Rubell +* Author(s): WIZnet, Arduino LLC, Bjoern Hartmann, Paul Stoffregen, Brent Rubell, + Patrick Van Oosterwijck Implementation Notes -------------------- @@ -113,6 +115,7 @@ # Maximum number of sockets to support, differs between chip versions. W5200_W5500_MAX_SOCK_NUM = const(0x08) +SOCKET_INVALID = const(255) # UDP socket struct. UDP_SOCK = {"bytes_remaining": 0, "remote_ip": 0, "remote_port": 0} @@ -144,7 +147,7 @@ def __init__( is_dhcp=True, mac=DEFAULT_MAC, hostname=None, - dhcp_timeout=3, + dhcp_timeout=30, debug=False, ): self._debug = debug @@ -240,7 +243,7 @@ def get_host_by_name(self, hostname): print("* Get host by name") if isinstance(hostname, str): hostname = bytes(hostname, "utf-8") - self._src_port = int(time.monotonic()) + self._src_port = int(time.monotonic()) & 0xFFFF # Return IP assigned by DHCP _dns_client = dns.DNS(self, self._dns, debug=self._debug) ret = _dns_client.gethostbyname(hostname) @@ -475,7 +478,6 @@ def socket_available(self, socket_num, sock_type=SNMR_TCP): res = self._get_rx_rcv_size(socket_num) - res = int.from_bytes(res, "b") if sock_type == SNMR_TCP: return res if res > 0: @@ -546,21 +548,22 @@ def _send_socket_cmd(self, socket, cmd): if self._debug: print("waiting for sncr to clear...") - def get_socket(self, sockets): + def get_socket(self): """Requests, allocates and returns a socket from the W5k chip. Returned socket number may not exceed max_sockets. - :parm int socket_num: Desired socket number """ if self._debug: print("*** Get socket") - sock = 0 - for _sock in range(len(sockets), self.max_sockets): - status = self.socket_status(_sock) - if ( - status[0] == SNSR_SOCK_CLOSED - or status[0] == SNSR_SOCK_FIN_WAIT - or status[0] == SNSR_SOCK_CLOSE_WAIT + sock = SOCKET_INVALID + for _sock in range(self.max_sockets): + status = self.socket_status(_sock)[0] + if status in ( + SNSR_SOCK_CLOSED, + SNSR_SOCK_TIME_WAIT, + SNSR_SOCK_FIN_WAIT, + SNSR_SOCK_CLOSE_WAIT, + SNSR_SOCK_CLOSING, ): sock = _sock break @@ -569,6 +572,32 @@ def get_socket(self, sockets): print("Allocated socket #{}".format(sock)) return sock + def socket_listen(self, socket_num, port): + """Start listening on a socket (TCP mode only). + :parm int socket_num: socket number + :parm int port: port to listen on + """ + assert self.link_status, "Ethernet cable disconnected!" + if self._debug: + print( + "* Listening on port={}, ip={}".format( + port, self.pretty_ip(self.ip_address) + ) + ) + # Initialize a socket and set the mode + self._src_port = port + res = self.socket_open(socket_num, conn_mode=SNMR_TCP) + if res == 1: + raise RuntimeError("Failed to initalize the socket.") + # Send listen command + self._send_socket_cmd(socket_num, CMD_SOCK_LISTEN) + # Wait until ready + status = [SNSR_SOCK_CLOSED] + while status[0] != SNSR_SOCK_LISTEN: + status = self._read_snsr(socket_num) + if status[0] == SNSR_SOCK_CLOSED: + raise RuntimeError("Listening socket closed.") + def socket_open(self, socket_num, conn_mode=SNMR_TCP): """Opens a TCP or UDP socket. By default, we use 'conn_mode'=SNMR_TCP but we may also use SNMR_UDP. @@ -576,7 +605,14 @@ def socket_open(self, socket_num, conn_mode=SNMR_TCP): assert self.link_status, "Ethernet cable disconnected!" if self._debug: print("*** Opening socket %d" % socket_num) - if self._read_snsr(socket_num)[0] == SNSR_SOCK_CLOSED: + status = self._read_snsr(socket_num)[0] + if status in ( + SNSR_SOCK_CLOSED, + SNSR_SOCK_TIME_WAIT, + SNSR_SOCK_FIN_WAIT, + SNSR_SOCK_CLOSE_WAIT, + SNSR_SOCK_CLOSING, + ): if self._debug: print("* Opening W5k Socket, protocol={}".format(conn_mode)) time.sleep(0.00025) @@ -624,7 +660,6 @@ def socket_read(self, socket_num, length): # Check if there is data available on the socket ret = self._get_rx_rcv_size(socket_num) - ret = int.from_bytes(ret, "b") if self._debug: print("Bytes avail. on sock: ", ret) if ret == 0: @@ -675,7 +710,7 @@ def read_udp(self, socket_num, length): return ret, resp return -1 - def socket_write(self, socket_num, buffer): + def socket_write(self, socket_num, buffer, timeout=0): """Writes a bytearray to a provided socket.""" assert self.link_status, "Ethernet cable disconnected!" assert socket_num <= self.max_sockets, "Provided socket exceeds max_sockets." @@ -686,13 +721,16 @@ def socket_write(self, socket_num, buffer): ret = SOCK_SIZE else: ret = len(buffer) + stamp = time.monotonic() # if buffer is available, start the transfer free_size = self._get_tx_free_size(socket_num) while free_size < ret: free_size = self._get_tx_free_size(socket_num) - status = self.socket_status(socket_num) - if status not in (SNSR_SOCK_ESTABLISHED, SNSR_SOCK_CLOSE_WAIT): + status = self.socket_status(socket_num)[0] + if status not in (SNSR_SOCK_ESTABLISHED, SNSR_SOCK_CLOSE_WAIT) or ( + timeout and time.monotonic() - stamp > timeout + ): ret = 0 break @@ -702,7 +740,7 @@ def socket_write(self, socket_num, buffer): dst_addr = offset + (socket_num * 2048 + 0x8000) # update sn_tx_wr to the value + data size - ptr += len(buffer) + ptr = (ptr + len(buffer)) & 0xFFFF self._write_sntx_wr(socket_num, ptr) cntl_byte = 0x14 + (socket_num << 5) @@ -715,7 +753,17 @@ def socket_write(self, socket_num, buffer): while ( self._read_socket(socket_num, REG_SNIR)[0] & SNIR_SEND_OK ) != SNIR_SEND_OK: - if self.socket_status(socket_num) == SNSR_SOCK_CLOSED: + if ( + self.socket_status(socket_num)[0] + in ( + SNSR_SOCK_CLOSED, + SNSR_SOCK_TIME_WAIT, + SNSR_SOCK_FIN_WAIT, + SNSR_SOCK_CLOSE_WAIT, + SNSR_SOCK_CLOSING, + ) + or (timeout and time.monotonic() - stamp > timeout) + ): # self.socket_close(socket_num) return 0 time.sleep(0.01) @@ -733,17 +781,17 @@ def _get_rx_rcv_size(self, sock): val_1 = self._read_snrx_rsr(sock) if val_1 != 0: val = self._read_snrx_rsr(sock) - return val + return int.from_bytes(val, "b") def _get_tx_free_size(self, sock): """Get free size of sock's tx buffer block.""" val = 0 - val_1 = 0 + val_1 = self._read_sntx_fsr(sock) while val != val_1: val_1 = self._read_sntx_fsr(sock) if val_1 != 0: val = self._read_sntx_fsr(sock) - return val + return int.from_bytes(val, "b") def _read_snrx_rd(self, sock): self._pbuff[0] = self._read_socket(sock, REG_SNRX_RD)[0] @@ -809,12 +857,10 @@ def _read_sncr(self, sock): def _read_snmr(self, sock): return self._read_socket(sock, REG_SNMR) - def _write_socket(self, sock, address, data, length=None): + def _write_socket(self, sock, address, data): """Write to a W5k socket register.""" base = self._ch_base_msb << 8 cntl_byte = (sock << 5) + 0x0C - if length is None: - return self.write(base + sock * CH_SIZE + address, cntl_byte, data) return self.write(base + sock * CH_SIZE + address, cntl_byte, data) def _read_socket(self, sock, address): diff --git a/adafruit_wiznet5k/adafruit_wiznet5k_dhcp.py b/adafruit_wiznet5k/adafruit_wiznet5k_dhcp.py old mode 100755 new mode 100644 index cbcbb42..7faef3d --- a/adafruit_wiznet5k/adafruit_wiznet5k_dhcp.py +++ b/adafruit_wiznet5k/adafruit_wiznet5k_dhcp.py @@ -84,7 +84,7 @@ class DHCP: # pylint: disable=too-many-arguments, too-many-instance-attributes, invalid-name def __init__( - self, eth, mac_address, hostname=None, response_timeout=3, debug=False + self, eth, mac_address, hostname=None, response_timeout=30, debug=False ): self._debug = debug self._response_timeout = response_timeout diff --git a/adafruit_wiznet5k/adafruit_wiznet5k_dns.py b/adafruit_wiznet5k/adafruit_wiznet5k_dns.py old mode 100755 new mode 100644 diff --git a/adafruit_wiznet5k/adafruit_wiznet5k_socket.py b/adafruit_wiznet5k/adafruit_wiznet5k_socket.py old mode 100755 new mode 100644 index 376514f..4033659 --- a/adafruit_wiznet5k/adafruit_wiznet5k_socket.py +++ b/adafruit_wiznet5k/adafruit_wiznet5k_socket.py @@ -9,7 +9,7 @@ A socket compatible interface with the Wiznet5k module. -* Author(s): ladyada, Brent Rubell +* Author(s): ladyada, Brent Rubell, Patrick Van Oosterwijck """ import gc @@ -45,10 +45,8 @@ def htons(x): TCP_MODE = 80 SOCK_DGRAM = const(0x02) # UDP AF_INET = const(3) -NO_SOCKET_AVAIL = const(255) +SOCKET_INVALID = const(255) -# keep track of sockets we allocate -SOCKETS = [] # pylint: disable=too-many-arguments, unused-argument def getaddrinfo(host, port, family=0, socktype=0, proto=0, flags=0): @@ -104,10 +102,11 @@ def __init__( self._sock_type = type self._buffer = b"" self._timeout = 0 + self._listen_port = None - self._socknum = _the_interface.get_socket(SOCKETS) - SOCKETS.append(self._socknum) - self.settimeout(self._timeout) + self._socknum = _the_interface.get_socket() + if self._socknum == SOCKET_INVALID: + raise RuntimeError("Failed to allocate socket.") @property def socknum(self): @@ -118,22 +117,19 @@ def socknum(self): def connected(self): """Returns whether or not we are connected to the socket.""" if self.socknum >= _the_interface.max_sockets: - return 0 + return False status = _the_interface.socket_status(self.socknum)[0] - if ( - status == adafruit_wiznet5k.SNSR_SOCK_CLOSE_WAIT - and self.available()[0] == 0 - ): + if status == adafruit_wiznet5k.SNSR_SOCK_CLOSE_WAIT and self.available() == 0: result = False - result = status not in ( - adafruit_wiznet5k.SNSR_SOCK_CLOSED, - adafruit_wiznet5k.SNSR_SOCK_LISTEN, - adafruit_wiznet5k.SNSR_SOCK_CLOSE_WAIT, - adafruit_wiznet5k.SNSR_SOCK_FIN_WAIT, - ) - if not result: + else: + result = status not in ( + adafruit_wiznet5k.SNSR_SOCK_CLOSED, + adafruit_wiznet5k.SNSR_SOCK_LISTEN, + adafruit_wiznet5k.SNSR_SOCK_TIME_WAIT, + adafruit_wiznet5k.SNSR_SOCK_FIN_WAIT, + ) + if not result and status != adafruit_wiznet5k.SNSR_SOCK_LISTEN: self.close() - return result return result def getpeername(self): @@ -150,6 +146,20 @@ def inet_aton(self, ip_string): self._buffer = bytearray(self._buffer) return self._buffer + def bind(self, address): + """Bind the socket to the listen port, we ignore the host. + :param tuple address: local socket as a (host, port) tuple, host is ignored. + """ + _, self._listen_port = address + + def listen(self, backlog=None): + """Listen on the port specified by bind. + :param backlog: For compatibility but ignored. + """ + assert self._listen_port is not None, "Use bind to set the port before listen!" + _the_interface.socket_listen(self.socknum, self._listen_port) + self._buffer = b"" + def connect(self, address, conntype=None): """Connect to a remote socket at address. (The format of address depends on the address family — see above.) @@ -175,7 +185,7 @@ def send(self, data): :param bytearray data: Desired data to send to the socket. """ - _the_interface.socket_write(self.socknum, data) + _the_interface.socket_write(self.socknum, data, self._timeout) gc.collect() def recv(self, bufsize=0): # pylint: disable=too-many-branches @@ -255,7 +265,11 @@ def readline(self): avail = _the_interface.udp_remaining() if avail: self._buffer += _the_interface.read_udp(self.socknum, avail) - elif self._timeout > 0 and time.monotonic() - stamp > self._timeout: + if ( + not avail + and self._timeout > 0 + and time.monotonic() - stamp > self._timeout + ): self.close() raise RuntimeError("Didn't receive response, failing out...") firstline, self._buffer = self._buffer.split(b"\r\n", 1) @@ -270,7 +284,6 @@ def disconnect(self): def close(self): """Closes the socket.""" _the_interface.socket_close(self.socknum) - SOCKETS.remove(self.socknum) def available(self): """Returns how many bytes of data are available to be read from the socket.""" diff --git a/adafruit_wiznet5k/adafruit_wiznet5k_wsgiserver.py b/adafruit_wiznet5k/adafruit_wiznet5k_wsgiserver.py new file mode 100644 index 0000000..5104c0f --- /dev/null +++ b/adafruit_wiznet5k/adafruit_wiznet5k_wsgiserver.py @@ -0,0 +1,190 @@ +# Based on ESP32 code Copyright (c) 2019 Matt Costi for Adafruit Industries +# SPDX-FileCopyrightText: Copyright (c) 2020 Patrick Van Oosterwijck +# +# SPDX-License-Identifier: MIT + +""" +`adafruit_wiznet5k_wsgiserver` +================================================================================ + +A simple WSGI (Web Server Gateway Interface) server that interfaces with the W5500. +Opens a listening port on the W5500 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 strings 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, Patrick Van Oosterwijck +""" +# pylint: disable=no-name-in-module + +import io +import gc +from micropython import const +import adafruit_wiznet5k.adafruit_wiznet5k_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) + + +# Maximum number of sockets for the web server (number of connections we can hold) +MAX_SOCK_NUM = const(6) + +# 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._timeout = 20 + self._client_sock = [] + 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. + """ + for _ in range(MAX_SOCK_NUM): + new_sock = socket.socket() + new_sock.settimeout(self._timeout) + new_sock.bind((None, self.port)) + new_sock.listen() + self._client_sock.append(new_sock) + if self._debug: + ip = _the_interface.pretty_ip(_the_interface.ip_address) + print("Server available at {0}:{1}".format(ip, self.port)) + + 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. + """ + add_sock = [] + for sock in self._client_sock: + if sock.available(): + environ = self._get_environ(sock) + result = self.application(environ, self._start_response) + self.finish_response(result, sock) + self._client_sock.remove(sock) + new_sock = socket.socket() + new_sock.settimeout(self._timeout) + new_sock.bind((None, self.port)) + new_sock.listen() + add_sock.append(new_sock) + self._client_sock.extend(add_sock) + + def finish_response(self, result, client): + """ + Called after the application callable 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. + :param Socket client: the socket to send the response to. + """ + 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" + client.send(response.encode("utf-8")) + for data in result: + if isinstance(data, bytes): + client.send(data) + else: + client.send(data.encode("utf-8")) + gc.collect() + finally: + client.disconnect() + client.close() + + 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", "w5kWSGIServer")] + 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 = {} + while True: + header = str(client.readline(), "utf-8") + if header == "": + break + title, content = header.split(": ", 1) + headers[title.lower()] = content + + 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() + 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