diff --git a/README.md b/README.md index d36191c..c2f961d 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,9 @@ This is a list of available webpages |-----|-------------| | `/` | Root index page, to choose from the available pages | | `/select` | Select and configure a network | -| `/configure` | Manage already configured networks | +| `/configure` | Manage already configured networks | | `/scan_result` | JSON of available networks | +| `/shutdown` | Shutdown webserver and return from `run` function | To leave from the Webinterface, just press CTRL+C and wait until all threads finish running. This takes around 1 second. The device will return to its REPL diff --git a/changelog.md b/changelog.md index 2f1b73c..9eb1e47 100644 --- a/changelog.md +++ b/changelog.md @@ -20,6 +20,14 @@ r"^\#\# \[\d{1,}[.]\d{1,}[.]\d{1,}\] \- \d{4}\-\d{2}-\d{2}$" ## Released +## [1.10.0] - 2023-02-18 +### Added +- `microdot_asyncio` in `microdot` folder +- `/shutdown` endpoint to stop webserver + +### Changed +- All webserver functions are `async`, see #28 + ## [1.9.0] - 2023-02-17 ### Added - `test-release` and `release` workflows create changelog based (pre-)releases @@ -240,8 +248,9 @@ r"^\#\# \[\d{1,}[.]\d{1,}[.]\d{1,}\] \- \d{4}\-\d{2}-\d{2}$" - `sendfile` function implemented in same way as on Micropythons PicoWeb -[Unreleased]: https://github.com/brainelectronics/Micropython-ESP-WiFi-Manager/compare/1.9.0...develop +[Unreleased]: https://github.com/brainelectronics/Micropython-ESP-WiFi-Manager/compare/1.10.0...develop +[1.10.0]: https://github.com/brainelectronics/Micropython-ESP-WiFi-Manager//tree/1.10.0 [1.9.0]: https://github.com/brainelectronics/Micropython-ESP-WiFi-Manager//tree/1.9.0 [1.8.0]: https://github.com/brainelectronics/Micropython-ESP-WiFi-Manager//tree/1.8.0 [1.7.1]: https://github.com/brainelectronics/Micropython-ESP-WiFi-Manager//tree/1.7.1 diff --git a/microdot/microdot_asyncio.py b/microdot/microdot_asyncio.py new file mode 100644 index 0000000..12f29e5 --- /dev/null +++ b/microdot/microdot_asyncio.py @@ -0,0 +1,446 @@ +""" +microdot_asyncio +---------------- + +The ``microdot_asyncio`` module defines a few classes that help implement +HTTP-based servers for MicroPython and standard Python that use ``asyncio`` +and coroutines. +""" +try: + import uasyncio as asyncio +except ImportError: + import asyncio + +try: + import uio as io +except ImportError: + import io + +from microdot import Microdot as BaseMicrodot +from microdot import mro +from microdot import NoCaseDict +from microdot import Request as BaseRequest +from microdot import Response as BaseResponse +from microdot import print_exception +from microdot import HTTPException +from microdot import MUTED_SOCKET_ERRORS + + +def _iscoroutine(coro): + return hasattr(coro, 'send') and hasattr(coro, 'throw') + + +class _AsyncBytesIO: + def __init__(self, data): + self.stream = io.BytesIO(data) + + async def read(self, n=-1): + return self.stream.read(n) + + async def readline(self): # pragma: no cover + return self.stream.readline() + + async def readexactly(self, n): # pragma: no cover + return self.stream.read(n) + + async def readuntil(self, separator=b'\n'): # pragma: no cover + return self.stream.readuntil(separator=separator) + + async def awrite(self, data): # pragma: no cover + return self.stream.write(data) + + async def aclose(self): # pragma: no cover + pass + + +class Request(BaseRequest): + @staticmethod + async def create(app, client_reader, client_writer, client_addr): + """Create a request object. + + :param app: The Microdot application instance. + :param client_reader: An input stream from where the request data can + be read. + :param client_writer: An output stream where the response data can be + written. + :param client_addr: The address of the client, as a tuple. + + This method is a coroutine. It returns a newly created ``Request`` + object. + """ + # request line + line = (await Request._safe_readline(client_reader)).strip().decode() + if not line: + return None + method, url, http_version = line.split() + http_version = http_version.split('/', 1)[1] + + # headers + headers = NoCaseDict() + content_length = 0 + while True: + line = (await Request._safe_readline( + client_reader)).strip().decode() + if line == '': + break + header, value = line.split(':', 1) + value = value.strip() + headers[header] = value + if header.lower() == 'content-length': + content_length = int(value) + + # body + body = b'' + if content_length and content_length <= Request.max_body_length: + body = await client_reader.readexactly(content_length) + stream = None + else: + body = b'' + stream = client_reader + + return Request(app, client_addr, method, url, http_version, headers, + body=body, stream=stream, + sock=(client_reader, client_writer)) + + @property + def stream(self): + if self._stream is None: + self._stream = _AsyncBytesIO(self._body) + return self._stream + + @staticmethod + async def _safe_readline(stream): + line = (await stream.readline()) + if len(line) > Request.max_readline: + raise ValueError('line too long') + return line + + +class Response(BaseResponse): + """An HTTP response class. + + :param body: The body of the response. If a dictionary or list is given, + a JSON formatter is used to generate the body. If a file-like + object or an async generator is given, a streaming response is + used. If a string is given, it is encoded from UTF-8. Else, + the body should be a byte sequence. + :param status_code: The numeric HTTP status code of the response. The + default is 200. + :param headers: A dictionary of headers to include in the response. + :param reason: A custom reason phrase to add after the status code. The + default is "OK" for responses with a 200 status code and + "N/A" for any other status codes. + """ + + async def write(self, stream): + self.complete() + + try: + # status code + reason = self.reason if self.reason is not None else \ + ('OK' if self.status_code == 200 else 'N/A') + await stream.awrite('HTTP/1.0 {status_code} {reason}\r\n'.format( + status_code=self.status_code, reason=reason).encode()) + + # headers + for header, value in self.headers.items(): + values = value if isinstance(value, list) else [value] + for value in values: + await stream.awrite('{header}: {value}\r\n'.format( + header=header, value=value).encode()) + await stream.awrite(b'\r\n') + + # body + async for body in self.body_iter(): + if isinstance(body, str): # pragma: no cover + body = body.encode() + await stream.awrite(body) + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS or \ + exc.args[0] == 'Connection lost': + pass + else: + raise + + def body_iter(self): + if hasattr(self.body, '__anext__'): + # response body is an async generator + return self.body + + response = self + + class iter: + def __aiter__(self): + if response.body: + self.i = 0 # need to determine type of response.body + else: + self.i = -1 # no response body + return self + + async def __anext__(self): + if self.i == -1: + raise StopAsyncIteration + if self.i == 0: + if hasattr(response.body, 'read'): + self.i = 2 # response body is a file-like object + elif hasattr(response.body, '__next__'): + self.i = 1 # response body is a sync generator + return next(response.body) + else: + self.i = -1 # response body is a plain string + return response.body + elif self.i == 1: + try: + return next(response.body) + except StopIteration: + raise StopAsyncIteration + buf = response.body.read(response.send_file_buffer_size) + if _iscoroutine(buf): # pragma: no cover + buf = await buf + if len(buf) < response.send_file_buffer_size: + self.i = -1 + if hasattr(response.body, 'close'): # pragma: no cover + result = response.body.close() + if _iscoroutine(result): + await result + return buf + + return iter() + + +class Microdot(BaseMicrodot): + async def start_server(self, host='0.0.0.0', port=5000, debug=False, + ssl=None): + """Start the Microdot web server as a coroutine. This coroutine does + not normally return, as the server enters an endless listening loop. + The :func:`shutdown` function provides a method for terminating the + server gracefully. + + :param host: The hostname or IP address of the network interface that + will be listening for requests. A value of ``'0.0.0.0'`` + (the default) indicates that the server should listen for + requests on all the available interfaces, and a value of + ``127.0.0.1`` indicates that the server should listen + for requests only on the internal networking interface of + the host. + :param port: The port number to listen for requests. The default is + port 5000. + :param debug: If ``True``, the server logs debugging information. The + default is ``False``. + :param ssl: An ``SSLContext`` instance or ``None`` if the server should + not use TLS. The default is ``None``. + + This method is a coroutine. + + Example:: + + import asyncio + from microdot_asyncio import Microdot + + app = Microdot() + + @app.route('/') + async def index(): + return 'Hello, world!' + + async def main(): + await app.start_server(debug=True) + + asyncio.run(main()) + """ + self.debug = debug + + async def serve(reader, writer): + if not hasattr(writer, 'awrite'): # pragma: no cover + # CPython provides the awrite and aclose methods in 3.8+ + async def awrite(self, data): + self.write(data) + await self.drain() + + async def aclose(self): + self.close() + await self.wait_closed() + + from types import MethodType + writer.awrite = MethodType(awrite, writer) + writer.aclose = MethodType(aclose, writer) + + await self.handle_request(reader, writer) + + if self.debug: # pragma: no cover + print('Starting async server on {host}:{port}...'.format( + host=host, port=port)) + + try: + self.server = await asyncio.start_server(serve, host, port, + ssl=ssl) + except TypeError: + self.server = await asyncio.start_server(serve, host, port) + + while True: + try: + await self.server.wait_closed() + break + except AttributeError: # pragma: no cover + # the task hasn't been initialized in the server object yet + # wait a bit and try again + await asyncio.sleep(0.1) + + def run(self, host='0.0.0.0', port=5000, debug=False, ssl=None): + """Start the web server. This function does not normally return, as + the server enters an endless listening loop. The :func:`shutdown` + function provides a method for terminating the server gracefully. + + :param host: The hostname or IP address of the network interface that + will be listening for requests. A value of ``'0.0.0.0'`` + (the default) indicates that the server should listen for + requests on all the available interfaces, and a value of + ``127.0.0.1`` indicates that the server should listen + for requests only on the internal networking interface of + the host. + :param port: The port number to listen for requests. The default is + port 5000. + :param debug: If ``True``, the server logs debugging information. The + default is ``False``. + :param ssl: An ``SSLContext`` instance or ``None`` if the server should + not use TLS. The default is ``None``. + + Example:: + + from microdot_asyncio import Microdot + + app = Microdot() + + @app.route('/') + async def index(): + return 'Hello, world!' + + app.run(debug=True) + """ + asyncio.run(self.start_server(host=host, port=port, debug=debug, + ssl=ssl)) + + def shutdown(self): + self.server.close() + + async def handle_request(self, reader, writer): + req = None + try: + req = await Request.create(self, reader, writer, + writer.get_extra_info('peername')) + except Exception as exc: # pragma: no cover + print_exception(exc) + + res = await self.dispatch_request(req) + if res != Response.already_handled: # pragma: no branch + await res.write(writer) + try: + await writer.aclose() + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS: + pass + else: + raise + if self.debug and req: # pragma: no cover + print('{method} {path} {status_code}'.format( + method=req.method, path=req.path, + status_code=res.status_code)) + + async def dispatch_request(self, req): + after_request_handled = False + if req: + if req.content_length > req.max_content_length: + if 413 in self.error_handlers: + res = await self._invoke_handler( + self.error_handlers[413], req) + else: + res = 'Payload too large', 413 + else: + f = self.find_route(req) + try: + res = None + if callable(f): + for handler in self.before_request_handlers: + res = await self._invoke_handler(handler, req) + if res: + break + if res is None: + res = await self._invoke_handler( + f, req, **req.url_args) + if isinstance(res, tuple): + body = res[0] + if isinstance(res[1], int): + status_code = res[1] + headers = res[2] if len(res) > 2 else {} + else: + status_code = 200 + headers = res[1] + res = Response(body, status_code, headers) + elif not isinstance(res, Response): + res = Response(res) + for handler in self.after_request_handlers: + res = await self._invoke_handler( + handler, req, res) or res + for handler in req.after_request_handlers: + res = await self._invoke_handler( + handler, req, res) or res + after_request_handled = True + elif f in self.error_handlers: + res = await self._invoke_handler( + self.error_handlers[f], req) + else: + res = 'Not found', f + except HTTPException as exc: + if exc.status_code in self.error_handlers: + res = self.error_handlers[exc.status_code](req) + else: + res = exc.reason, exc.status_code + except Exception as exc: + print_exception(exc) + exc_class = None + res = None + if exc.__class__ in self.error_handlers: + exc_class = exc.__class__ + else: + for c in mro(exc.__class__)[1:]: + if c in self.error_handlers: + exc_class = c + break + if exc_class: + try: + res = await self._invoke_handler( + self.error_handlers[exc_class], req, exc) + except Exception as exc2: # pragma: no cover + print_exception(exc2) + if res is None: + if 500 in self.error_handlers: + res = await self._invoke_handler( + self.error_handlers[500], req) + else: + res = 'Internal server error', 500 + else: + if 400 in self.error_handlers: + res = await self._invoke_handler(self.error_handlers[400], req) + else: + res = 'Bad request', 400 + if isinstance(res, tuple): + res = Response(*res) + elif not isinstance(res, Response): + res = Response(res) + if not after_request_handled: + for handler in self.after_error_request_handlers: + res = await self._invoke_handler( + handler, req, res) or res + return res + + async def _invoke_handler(self, f_or_coro, *args, **kwargs): + ret = f_or_coro(*args, **kwargs) + if _iscoroutine(ret): + ret = await ret + return ret + + +abort = Microdot.abort +Response.already_handled = Response() +redirect = Response.redirect +send_file = Response.send_file diff --git a/wifi_manager/wifi_manager.py b/wifi_manager/wifi_manager.py index 18ec4f7..1c54f10 100644 --- a/wifi_manager/wifi_manager.py +++ b/wifi_manager/wifi_manager.py @@ -26,8 +26,9 @@ # pip installed packages # https://github.com/miguelgrinberg/microdot -from microdot import Microdot, redirect, Request, Response, send_file, \ - URLPattern +from microdot.microdot_asyncio import Microdot, redirect, Request, Response, \ + send_file +from microdot import URLPattern from microdot.microdot_utemplate import render_template, init_templates # custom packages @@ -283,6 +284,8 @@ def _add_app_routes(self) -> None: func=self.serve_static) self.add_url_rule(url='/favicon.ico', func=self.serve_favicon) + self.add_url_rule(url='/shutdown', func=self.shutdown) + self.app.error_handlers[404] = self.not_found available_urls = { @@ -768,7 +771,7 @@ def _remove_wifi_config(self, form_data: dict) -> None: # Webserver functions # @app.route('/') - def landing_page(self, req: Request) -> None: + async def landing_page(self, req: Request) -> None: """ Provide landing page aka index page @@ -776,18 +779,18 @@ def landing_page(self, req: Request) -> None: @see available_urls property """ available_pages = self.available_urls - content = self._render_index_page(available_pages) + content = await self._render_index_page(available_pages) return render_template(template='index.tpl', req=None, content=content) # @app.route('/scan_result') - def scan_result(self, req: Request) -> None: + async def scan_result(self, req: Request) -> None: """Provide latest found networks as JSON""" # https://microdot.readthedocs.io/en/latest/intro.html#json-responses return self.latest_scan # @app.route('/select') - def wifi_selection(self, req: Request) -> None: + async def wifi_selection(self, req: Request) -> None: """ Provide webpage to select WiFi network from list of available networks @@ -797,7 +800,9 @@ def wifi_selection(self, req: Request) -> None: 0.02sec to complete """ available_nets = self.latest_scan - content = self._render_network_inputs(available_nets=available_nets) + content = await self._render_network_inputs( + available_nets=available_nets + ) # do not stop scanning as page is updating scan results on the fly # with XMLHTTP requests to @see scan_result @@ -808,17 +813,19 @@ def wifi_selection(self, req: Request) -> None: return render_template(template='select.tpl', req=0, content=content) # @app.route('/render_network_inputs') - def render_network_inputs(self, req: Request) -> str: + async def render_network_inputs(self, req: Request) -> str: """Return rendered network inputs content to webpage""" available_nets = self.latest_scan selected_bssid = self._selected_network_bssid - content = self._render_network_inputs(available_nets=available_nets, - selected_bssid=selected_bssid) + content = await self._render_network_inputs( + available_nets=available_nets, + selected_bssid=selected_bssid + ) return content # @app.route('/configure') - def wifi_configs(self, req: Request) -> None: + async def wifi_configs(self, req: Request) -> None: """Provide webpage with table of configured networks""" configured_nets = self.configured_networks self.logger.debug('Existing config content: {}'. @@ -833,7 +840,7 @@ def wifi_configs(self, req: Request) -> None: button_mode='disabled') # @app.route('/save_wifi_config') - def save_wifi_config(self, req: Request) -> None: + async def save_wifi_config(self, req: Request) -> None: """Process saving the specified WiFi network to the WiFi config file""" form_data = req.form @@ -842,30 +849,30 @@ def save_wifi_config(self, req: Request) -> None: self.logger.info('WiFi user input content: {}'.format(form_data)) # {'ssid': '', 'wifi_network': 'a0f3c1fbfc3c', 'password': 'qwertz'} - self._save_wifi_config(form_data=form_data) + await self._save_wifi_config(form_data=form_data) # empty response to avoid any redirects or errors due to none response return None, 204, {'Content-Type': 'application/json; charset=UTF-8'} # @app.route('/remove_wifi_config') - def remove_wifi_config(self, req: Request) -> None: + async def remove_wifi_config(self, req: Request) -> None: """Remove a network from the list of configured networks""" form_data = req.form self.logger.info('Remove networks: {}'.format(form_data)) # Remove networks: {'FRITZ!Box 7490': 'FRITZ!Box 7490'} - self._remove_wifi_config(form_data=form_data) + await self._remove_wifi_config(form_data=form_data) # redirect to '/configure' return redirect('/configure') # @app.route('/static/') - def serve_static(self, - req: Request, - path: str) -> Union[str, - Tuple[str, int], - Tuple[str, int, dict]]: + async def serve_static(self, + req: Request, + path: str) -> Union[str, + Tuple[str, int], + Tuple[str, int, dict]]: if '..' in path: # directory traversal is not allowed return 'Not found', 404 @@ -892,14 +899,21 @@ def serve_static(self, return f, 200, response_header - # @app.route("/favicon.ico") - def serve_favicon(self, req: Request) -> None: + # @app.route('/favicon.ico') + async def serve_favicon(self, req: Request) -> None: # return None, 204, {'Content-Type': 'application/json; charset=UTF-8'} return send_file(filename='/lib/static/favicon.ico', status_code=200, content_type='image/x-icon') - def not_found(self, req: Request) -> None: + # @app.route('/shutdown') + async def shutdown(self, req: Request) -> None: + """Shutdown webserver""" + req.app.shutdown() + + return 'The server is shutting down...' + + async def not_found(self, req: Request) -> None: return {'error': 'resource not found'}, 404 def run(self,