diff --git a/esp32/lib/microdot/__init__.py b/esp32/lib/microdot/__init__.py new file mode 100644 index 0000000..68cb381 --- /dev/null +++ b/esp32/lib/microdot/__init__.py @@ -0,0 +1,2 @@ +from microdot.microdot import Microdot, Request, Response, abort, redirect, \ + send_file # noqa: F401 \ No newline at end of file diff --git a/esp32/lib/microdot/helpers.py b/esp32/lib/microdot/helpers.py new file mode 100644 index 0000000..664e58c --- /dev/null +++ b/esp32/lib/microdot/helpers.py @@ -0,0 +1,8 @@ +try: + from functools import wraps +except ImportError: # pragma: no cover + # MicroPython does not currently implement functools.wraps + def wraps(wrapped): + def _(wrapper): + return wrapper + return _ diff --git a/esp32/lib/microdot/microdot.py b/esp32/lib/microdot/microdot.py new file mode 100644 index 0000000..0513f21 --- /dev/null +++ b/esp32/lib/microdot/microdot.py @@ -0,0 +1,1450 @@ +""" +microdot +-------- + +The ``microdot`` module defines a few classes that help implement HTTP-based +servers for MicroPython and standard Python. +""" +import asyncio +import io +import json +import time + +try: + from inspect import iscoroutinefunction, iscoroutine + from functools import partial + + async def invoke_handler(handler, *args, **kwargs): + """Invoke a handler and return the result. + + This method runs sync handlers in a thread pool executor. + """ + if iscoroutinefunction(handler): + ret = await handler(*args, **kwargs) + else: + ret = await asyncio.get_running_loop().run_in_executor( + None, partial(handler, *args, **kwargs)) + return ret +except ImportError: # pragma: no cover + def iscoroutine(coro): + return hasattr(coro, 'send') and hasattr(coro, 'throw') + + async def invoke_handler(handler, *args, **kwargs): + """Invoke a handler and return the result. + + This method runs sync handlers in the asyncio thread, which can + potentially cause blocking and performance issues. + """ + ret = handler(*args, **kwargs) + if iscoroutine(ret): + ret = await ret + return ret + +try: + from sys import print_exception +except ImportError: # pragma: no cover + import traceback + + def print_exception(exc): + traceback.print_exc() + +MUTED_SOCKET_ERRORS = [ + 32, # Broken pipe + 54, # Connection reset by peer + 104, # Connection reset by peer + 128, # Operation on closed socket +] + + +def urldecode_str(s): + s = s.replace('+', ' ') + parts = s.split('%') + if len(parts) == 1: + return s + result = [parts[0]] + for item in parts[1:]: + if item == '': + result.append('%') + else: + code = item[:2] + result.append(chr(int(code, 16))) + result.append(item[2:]) + return ''.join(result) + + +def urldecode_bytes(s): + s = s.replace(b'+', b' ') + parts = s.split(b'%') + if len(parts) == 1: + return s.decode() + result = [parts[0]] + for item in parts[1:]: + if item == b'': + result.append(b'%') + else: + code = item[:2] + result.append(bytes([int(code, 16)])) + result.append(item[2:]) + return b''.join(result).decode() + + +def urlencode(s): + return s.replace('+', '%2B').replace(' ', '+').replace( + '%', '%25').replace('?', '%3F').replace('#', '%23').replace( + '&', '%26').replace('=', '%3D') + + +class NoCaseDict(dict): + """A subclass of dictionary that holds case-insensitive keys. + + :param initial_dict: an initial dictionary of key/value pairs to + initialize this object with. + + Example:: + + >>> d = NoCaseDict() + >>> d['Content-Type'] = 'text/html' + >>> print(d['Content-Type']) + text/html + >>> print(d['content-type']) + text/html + >>> print(d['CONTENT-TYPE']) + text/html + >>> del d['cOnTeNt-TyPe'] + >>> print(d) + {} + """ + def __init__(self, initial_dict=None): + super().__init__(initial_dict or {}) + self.keymap = {k.lower(): k for k in self.keys() if k.lower() != k} + + def __setitem__(self, key, value): + kl = key.lower() + key = self.keymap.get(kl, key) + if kl != key: + self.keymap[kl] = key + super().__setitem__(key, value) + + def __getitem__(self, key): + kl = key.lower() + return super().__getitem__(self.keymap.get(kl, kl)) + + def __delitem__(self, key): + kl = key.lower() + super().__delitem__(self.keymap.get(kl, kl)) + + def __contains__(self, key): + kl = key.lower() + return self.keymap.get(kl, kl) in self.keys() + + def get(self, key, default=None): + kl = key.lower() + return super().get(self.keymap.get(kl, kl), default) + + def update(self, other_dict): + for key, value in other_dict.items(): + self[key] = value + + +def mro(cls): # pragma: no cover + """Return the method resolution order of a class. + + This is a helper function that returns the method resolution order of a + class. It is used by Microdot to find the best error handler to invoke for + the raised exception. + + In CPython, this function returns the ``__mro__`` attribute of the class. + In MicroPython, this function implements a recursive depth-first scanning + of the class hierarchy. + """ + if hasattr(cls, 'mro'): + return cls.__mro__ + + def _mro(cls): + m = [cls] + for base in cls.__bases__: + m += _mro(base) + return m + + mro_list = _mro(cls) + + # If a class appears multiple times (due to multiple inheritance) remove + # all but the last occurence. This matches the method resolution order + # of MicroPython, but not CPython. + mro_pruned = [] + for i in range(len(mro_list)): + base = mro_list.pop(0) + if base not in mro_list: + mro_pruned.append(base) + return mro_pruned + + +class MultiDict(dict): + """A subclass of dictionary that can hold multiple values for the same + key. It is used to hold key/value pairs decoded from query strings and + form submissions. + + :param initial_dict: an initial dictionary of key/value pairs to + initialize this object with. + + Example:: + + >>> d = MultiDict() + >>> d['sort'] = 'name' + >>> d['sort'] = 'email' + >>> print(d['sort']) + 'name' + >>> print(d.getlist('sort')) + ['name', 'email'] + """ + def __init__(self, initial_dict=None): + super().__init__() + if initial_dict: + for key, value in initial_dict.items(): + self[key] = value + + def __setitem__(self, key, value): + if key not in self: + super().__setitem__(key, []) + super().__getitem__(key).append(value) + + def __getitem__(self, key): + return super().__getitem__(key)[0] + + def get(self, key, default=None, type=None): + """Return the value for a given key. + + :param key: The key to retrieve. + :param default: A default value to use if the key does not exist. + :param type: A type conversion callable to apply to the value. + + If the multidict contains more than one value for the requested key, + this method returns the first value only. + + Example:: + + >>> d = MultiDict() + >>> d['age'] = '42' + >>> d.get('age') + '42' + >>> d.get('age', type=int) + 42 + >>> d.get('name', default='noname') + 'noname' + """ + if key not in self: + return default + value = self[key] + if type is not None: + value = type(value) + return value + + def getlist(self, key, type=None): + """Return all the values for a given key. + + :param key: The key to retrieve. + :param type: A type conversion callable to apply to the values. + + If the requested key does not exist in the dictionary, this method + returns an empty list. + + Example:: + + >>> d = MultiDict() + >>> d.getlist('items') + [] + >>> d['items'] = '3' + >>> d.getlist('items') + ['3'] + >>> d['items'] = '56' + >>> d.getlist('items') + ['3', '56'] + >>> d.getlist('items', type=int) + [3, 56] + """ + if key not in self: + return [] + values = super().__getitem__(key) + if type is not None: + values = [type(value) for value in values] + return values + + +class AsyncBytesIO: + """An async wrapper for BytesIO.""" + 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: + """An HTTP request.""" + #: Specify the maximum payload size that is accepted. Requests with larger + #: payloads will be rejected with a 413 status code. Applications can + #: change this maximum as necessary. + #: + #: Example:: + #: + #: Request.max_content_length = 1 * 1024 * 1024 # 1MB requests allowed + max_content_length = 16 * 1024 + + #: Specify the maximum payload size that can be stored in ``body``. + #: Requests with payloads that are larger than this size and up to + #: ``max_content_length`` bytes will be accepted, but the application will + #: only be able to access the body of the request by reading from + #: ``stream``. Set to 0 if you always access the body as a stream. + #: + #: Example:: + #: + #: Request.max_body_length = 4 * 1024 # up to 4KB bodies read + max_body_length = 16 * 1024 + + #: Specify the maximum length allowed for a line in the request. Requests + #: with longer lines will not be correctly interpreted. Applications can + #: change this maximum as necessary. + #: + #: Example:: + #: + #: Request.max_readline = 16 * 1024 # 16KB lines allowed + max_readline = 2 * 1024 + + class G: + pass + + def __init__(self, app, client_addr, method, url, http_version, headers, + body=None, stream=None, sock=None): + #: The application instance to which this request belongs. + self.app = app + #: The address of the client, as a tuple (host, port). + self.client_addr = client_addr + #: The HTTP method of the request. + self.method = method + #: The request URL, including the path and query string. + self.url = url + #: The path portion of the URL. + self.path = url + #: The query string portion of the URL. + self.query_string = None + #: The parsed query string, as a + #: :class:`MultiDict ` object. + self.args = {} + #: A dictionary with the headers included in the request. + self.headers = headers + #: A dictionary with the cookies included in the request. + self.cookies = {} + #: The parsed ``Content-Length`` header. + self.content_length = 0 + #: The parsed ``Content-Type`` header. + self.content_type = None + #: A general purpose container for applications to store data during + #: the life of the request. + self.g = Request.G() + + self.http_version = http_version + if '?' in self.path: + self.path, self.query_string = self.path.split('?', 1) + self.args = self._parse_urlencoded(self.query_string) + + if 'Content-Length' in self.headers: + self.content_length = int(self.headers['Content-Length']) + if 'Content-Type' in self.headers: + self.content_type = self.headers['Content-Type'] + if 'Cookie' in self.headers: + for cookie in self.headers['Cookie'].split(';'): + name, value = cookie.strip().split('=', 1) + self.cookies[name] = value + + self._body = body + self.body_used = False + self._stream = stream + self.sock = sock + self._json = None + self._form = None + self.after_request_handlers = [] + + @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: # pragma: no cover + 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)) + + def _parse_urlencoded(self, urlencoded): + data = MultiDict() + if len(urlencoded) > 0: # pragma: no branch + if isinstance(urlencoded, str): + for kv in [pair.split('=', 1) + for pair in urlencoded.split('&') if pair]: + data[urldecode_str(kv[0])] = urldecode_str(kv[1]) \ + if len(kv) > 1 else '' + elif isinstance(urlencoded, bytes): # pragma: no branch + for kv in [pair.split(b'=', 1) + for pair in urlencoded.split(b'&') if pair]: + data[urldecode_bytes(kv[0])] = urldecode_bytes(kv[1]) \ + if len(kv) > 1 else b'' + return data + + @property + def body(self): + """The body of the request, as bytes.""" + return self._body + + @property + def stream(self): + """The body of the request, as a bytes stream.""" + if self._stream is None: + self._stream = AsyncBytesIO(self._body) + return self._stream + + @property + def json(self): + """The parsed JSON body, or ``None`` if the request does not have a + JSON body.""" + if self._json is None: + if self.content_type is None: + return None + mime_type = self.content_type.split(';')[0] + if mime_type != 'application/json': + return None + self._json = json.loads(self.body.decode()) + return self._json + + @property + def form(self): + """The parsed form submission body, as a + :class:`MultiDict ` object, or ``None`` if the + request does not have a form submission.""" + if self._form is None: + if self.content_type is None: + return None + mime_type = self.content_type.split(';')[0] + if mime_type != 'application/x-www-form-urlencoded': + return None + self._form = self._parse_urlencoded(self.body) + return self._form + + def after_request(self, f): + """Register a request-specific function to run after the request is + handled. Request-specific after request handlers run at the very end, + after the application's own after request handlers. The function must + take two arguments, the request and response objects. The return value + of the function must be the updated response object. + + Example:: + + @app.route('/') + def index(request): + # register a request-specific after request handler + @req.after_request + def func(request, response): + # ... + return response + + return 'Hello, World!' + + Note that the function is not called if the request handler raises an + exception and an error response is returned instead. + """ + self.after_request_handlers.append(f) + return f + + @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: + """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. + """ + types_map = { + 'css': 'text/css', + 'gif': 'image/gif', + 'html': 'text/html', + 'jpg': 'image/jpeg', + 'js': 'application/javascript', + 'json': 'application/json', + 'png': 'image/png', + 'txt': 'text/plain', + } + + send_file_buffer_size = 1024 + + #: The content type to use for responses that do not explicitly define a + #: ``Content-Type`` header. + default_content_type = 'text/plain' + + #: The default cache control max age used by :meth:`send_file`. A value + #: of ``None`` means that no ``Cache-Control`` header is added. + default_send_file_max_age = None + + #: Special response used to signal that a response does not need to be + #: written to the client. Used to exit WebSocket connections cleanly. + already_handled = None + + def __init__(self, body='', status_code=200, headers=None, reason=None): + if body is None and status_code == 200: + body = '' + status_code = 204 + self.status_code = status_code + self.headers = NoCaseDict(headers or {}) + self.reason = reason + if isinstance(body, (dict, list)): + self.body = json.dumps(body).encode() + self.headers['Content-Type'] = 'application/json; charset=UTF-8' + elif isinstance(body, str): + self.body = body.encode() + else: + # this applies to bytes, file-like objects or generators + self.body = body + self.is_head = False + + def set_cookie(self, cookie, value, path=None, domain=None, expires=None, + max_age=None, secure=False, http_only=False, + partitioned=False): + """Add a cookie to the response. + + :param cookie: The cookie's name. + :param value: The cookie's value. + :param path: The cookie's path. + :param domain: The cookie's domain. + :param expires: The cookie expiration time, as a ``datetime`` object + or a correctly formatted string. + :param max_age: The cookie's ``Max-Age`` value. + :param secure: The cookie's ``secure`` flag. + :param http_only: The cookie's ``HttpOnly`` flag. + :param partitioned: Whether the cookie is partitioned. + """ + http_cookie = '{cookie}={value}'.format(cookie=cookie, value=value) + if path: + http_cookie += '; Path=' + path + if domain: + http_cookie += '; Domain=' + domain + if expires: + if isinstance(expires, str): + http_cookie += '; Expires=' + expires + else: # pragma: no cover + http_cookie += '; Expires=' + time.strftime( + '%a, %d %b %Y %H:%M:%S GMT', expires.timetuple()) + if max_age is not None: + http_cookie += '; Max-Age=' + str(max_age) + if secure: + http_cookie += '; Secure' + if http_only: + http_cookie += '; HttpOnly' + if partitioned: + http_cookie += '; Partitioned' + if 'Set-Cookie' in self.headers: + self.headers['Set-Cookie'].append(http_cookie) + else: + self.headers['Set-Cookie'] = [http_cookie] + + def delete_cookie(self, cookie, **kwargs): + """Delete a cookie. + + :param cookie: The cookie's name. + :param kwargs: Any cookie opens and flags supported by + ``set_cookie()`` except ``expires`` and ``max_age``. + """ + self.set_cookie(cookie, '', expires='Thu, 01 Jan 1970 00:00:01 GMT', + max_age=0, **kwargs) + + def complete(self): + if isinstance(self.body, bytes) and \ + 'Content-Length' not in self.headers: + self.headers['Content-Length'] = str(len(self.body)) + if 'Content-Type' not in self.headers: + self.headers['Content-Type'] = self.default_content_type + if 'charset=' not in self.headers['Content-Type']: + self.headers['Content-Type'] += '; charset=UTF-8' + + 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 + if not self.is_head: + iter = self.body_iter() + async for body in iter: + if isinstance(body, str): # pragma: no cover + body = body.encode() + try: + await stream.awrite(body) + except OSError as exc: # pragma: no cover + if exc.errno in MUTED_SOCKET_ERRORS or \ + exc.args[0] == 'Connection lost': + if hasattr(iter, 'aclose'): + await iter.aclose() + raise + if hasattr(iter, 'aclose'): # pragma: no branch + await iter.aclose() + + 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: + ITER_UNKNOWN = 0 + ITER_SYNC_GEN = 1 + ITER_FILE_OBJ = 2 + ITER_NO_BODY = -1 + + def __aiter__(self): + if response.body: + self.i = self.ITER_UNKNOWN # need to determine type + else: + self.i = self.ITER_NO_BODY + return self + + async def __anext__(self): + if self.i == self.ITER_NO_BODY: + await self.aclose() + raise StopAsyncIteration + if self.i == self.ITER_UNKNOWN: + if hasattr(response.body, 'read'): + self.i = self.ITER_FILE_OBJ + elif hasattr(response.body, '__next__'): + self.i = self.ITER_SYNC_GEN + return next(response.body) + else: + self.i = self.ITER_NO_BODY + return response.body + elif self.i == self.ITER_SYNC_GEN: + try: + return next(response.body) + except StopIteration: + await self.aclose() + 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 = self.ITER_NO_BODY + return buf + + async def aclose(self): + if hasattr(response.body, 'close'): + result = response.body.close() + if iscoroutine(result): # pragma: no cover + await result + + return iter() + + @classmethod + def redirect(cls, location, status_code=302): + """Return a redirect response. + + :param location: The URL to redirect to. + :param status_code: The 3xx status code to use for the redirect. The + default is 302. + """ + if '\x0d' in location or '\x0a' in location: + raise ValueError('invalid redirect URL') + return cls(status_code=status_code, headers={'Location': location}) + + @classmethod + def send_file(cls, filename, status_code=200, content_type=None, + stream=None, max_age=None, compressed=False, + file_extension=''): + """Send file contents in a response. + + :param filename: The filename of the file. + :param status_code: The 3xx status code to use for the redirect. The + default is 302. + :param content_type: The ``Content-Type`` header to use in the + response. If omitted, it is generated + automatically from the file extension of the + ``filename`` parameter. + :param stream: A file-like object to read the file contents from. If + a stream is given, the ``filename`` parameter is only + used when generating the ``Content-Type`` header. + :param max_age: The ``Cache-Control`` header's ``max-age`` value in + seconds. If omitted, the value of the + :attr:`Response.default_send_file_max_age` attribute is + used. + :param compressed: Whether the file is compressed. If ``True``, the + ``Content-Encoding`` header is set to ``gzip``. A + string with the header value can also be passed. + Note that when using this option the file must have + been compressed beforehand. This option only sets + the header. + :param file_extension: A file extension to append to the ``filename`` + parameter when opening the file, including the + dot. The extension given here is not considered + when generating the ``Content-Type`` header. + + Security note: The filename is assumed to be trusted. Never pass + filenames provided by the user without validating and sanitizing them + first. + """ + if content_type is None: + if compressed and filename.endswith('.gz'): + ext = filename[:-3].split('.')[-1] + else: + ext = filename.split('.')[-1] + if ext in Response.types_map: + content_type = Response.types_map[ext] + else: + content_type = 'application/octet-stream' + headers = {'Content-Type': content_type} + + if max_age is None: + max_age = cls.default_send_file_max_age + if max_age is not None: + headers['Cache-Control'] = 'max-age={}'.format(max_age) + + if compressed: + headers['Content-Encoding'] = compressed \ + if isinstance(compressed, str) else 'gzip' + + f = stream or open(filename + file_extension, 'rb') + return cls(body=f, status_code=status_code, headers=headers) + + +class URLPattern(): + def __init__(self, url_pattern): + self.url_pattern = url_pattern + self.segments = [] + self.regex = None + pattern = '' + use_regex = False + for segment in url_pattern.lstrip('/').split('/'): + if segment and segment[0] == '<': + if segment[-1] != '>': + raise ValueError('invalid URL pattern') + segment = segment[1:-1] + if ':' in segment: + type_, name = segment.rsplit(':', 1) + else: + type_ = 'string' + name = segment + parser = None + if type_ == 'string': + parser = self._string_segment + pattern += '/([^/]+)' + elif type_ == 'int': + parser = self._int_segment + pattern += '/(-?\\d+)' + elif type_ == 'path': + use_regex = True + pattern += '/(.+)' + elif type_.startswith('re:'): + use_regex = True + pattern += '/({pattern})'.format(pattern=type_[3:]) + else: + raise ValueError('invalid URL segment type') + self.segments.append({'parser': parser, 'name': name, + 'type': type_}) + else: + pattern += '/' + segment + self.segments.append({'parser': self._static_segment(segment)}) + if use_regex: + import re + self.regex = re.compile('^' + pattern + '$') + + def match(self, path): + args = {} + if self.regex: + g = self.regex.match(path) + if not g: + return + i = 1 + for segment in self.segments: + if 'name' not in segment: + continue + value = g.group(i) + if segment['type'] == 'int': + value = int(value) + args[segment['name']] = value + i += 1 + else: + if len(path) == 0 or path[0] != '/': + return + path = path[1:] + args = {} + for segment in self.segments: + if path is None: + return + arg, path = segment['parser'](path) + if arg is None: + return + if 'name' in segment: + args[segment['name']] = arg + if path is not None: + return + return args + + def _static_segment(self, segment): + def _static(value): + s = value.split('/', 1) + if s[0] == segment: + return '', s[1] if len(s) > 1 else None + return None, None + return _static + + def _string_segment(self, value): + s = value.split('/', 1) + if len(s[0]) == 0: + return None, None + return s[0], s[1] if len(s) > 1 else None + + def _int_segment(self, value): + s = value.split('/', 1) + try: + return int(s[0]), s[1] if len(s) > 1 else None + except ValueError: + return None, None + + +class HTTPException(Exception): + def __init__(self, status_code, reason=None): + self.status_code = status_code + self.reason = reason or str(status_code) + ' error' + + def __repr__(self): # pragma: no cover + return 'HTTPException: {}'.format(self.status_code) + + +class Microdot: + """An HTTP application class. + + This class implements an HTTP application instance and is heavily + influenced by the ``Flask`` class of the Flask framework. It is typically + declared near the start of the main application script. + + Example:: + + from microdot import Microdot + + app = Microdot() + """ + + def __init__(self): + self.url_map = [] + self.before_request_handlers = [] + self.after_request_handlers = [] + self.after_error_request_handlers = [] + self.error_handlers = {} + self.shutdown_requested = False + self.options_handler = self.default_options_handler + self.debug = False + self.server = None + + def route(self, url_pattern, methods=None): + """Decorator that is used to register a function as a request handler + for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + :param methods: The list of HTTP methods to be handled by the + decorated function. If omitted, only ``GET`` requests + are handled. + + The URL pattern can be a static path (for example, ``/users`` or + ``/api/invoices/search``) or a path with dynamic components enclosed + in ``<`` and ``>`` (for example, ``/users/`` or + ``/invoices//products``). Dynamic path components can also + include a type prefix, separated from the name with a colon (for + example, ``/users/``). The type can be ``string`` (the + default), ``int``, ``path`` or ``re:[regular-expression]``. + + The first argument of the decorated function must be + the request object. Any path arguments that are specified in the URL + pattern are passed as keyword arguments. The return value of the + function must be a :class:`Response` instance, or the arguments to + be passed to this class. + + Example:: + + @app.route('/') + def index(request): + return 'Hello, world!' + """ + def decorated(f): + self.url_map.append( + ([m.upper() for m in (methods or ['GET'])], + URLPattern(url_pattern), f)) + return f + return decorated + + def get(self, url_pattern): + """Decorator that is used to register a function as a ``GET`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['GET']``. + + Example:: + + @app.get('/users/') + def get_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['GET']) + + def post(self, url_pattern): + """Decorator that is used to register a function as a ``POST`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the``route`` decorator with + ``methods=['POST']``. + + Example:: + + @app.post('/users') + def create_user(request): + # ... + """ + return self.route(url_pattern, methods=['POST']) + + def put(self, url_pattern): + """Decorator that is used to register a function as a ``PUT`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['PUT']``. + + Example:: + + @app.put('/users/') + def edit_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['PUT']) + + def patch(self, url_pattern): + """Decorator that is used to register a function as a ``PATCH`` request + handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['PATCH']``. + + Example:: + + @app.patch('/users/') + def edit_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['PATCH']) + + def delete(self, url_pattern): + """Decorator that is used to register a function as a ``DELETE`` + request handler for a given URL. + + :param url_pattern: The URL pattern that will be compared against + incoming requests. + + This decorator can be used as an alias to the ``route`` decorator with + ``methods=['DELETE']``. + + Example:: + + @app.delete('/users/') + def delete_user(request, id): + # ... + """ + return self.route(url_pattern, methods=['DELETE']) + + def before_request(self, f): + """Decorator to register a function to run before each request is + handled. The decorated function must take a single argument, the + request object. + + Example:: + + @app.before_request + def func(request): + # ... + """ + self.before_request_handlers.append(f) + return f + + def after_request(self, f): + """Decorator to register a function to run after each request is + handled. The decorated function must take two arguments, the request + and response objects. The return value of the function must be an + updated response object. + + Example:: + + @app.after_request + def func(request, response): + # ... + return response + """ + self.after_request_handlers.append(f) + return f + + def after_error_request(self, f): + """Decorator to register a function to run after an error response is + generated. The decorated function must take two arguments, the request + and response objects. The return value of the function must be an + updated response object. The handler is invoked for error responses + generated by Microdot, as well as those returned by application-defined + error handlers. + + Example:: + + @app.after_error_request + def func(request, response): + # ... + return response + """ + self.after_error_request_handlers.append(f) + return f + + def errorhandler(self, status_code_or_exception_class): + """Decorator to register a function as an error handler. Error handler + functions for numeric HTTP status codes must accept a single argument, + the request object. Error handler functions for Python exceptions + must accept two arguments, the request object and the exception + object. + + :param status_code_or_exception_class: The numeric HTTP status code or + Python exception class to + handle. + + Examples:: + + @app.errorhandler(404) + def not_found(request): + return 'Not found' + + @app.errorhandler(RuntimeError) + def runtime_error(request, exception): + return 'Runtime error' + """ + def decorated(f): + self.error_handlers[status_code_or_exception_class] = f + return f + return decorated + + def mount(self, subapp, url_prefix=''): + """Mount a sub-application, optionally under the given URL prefix. + + :param subapp: The sub-application to mount. + :param url_prefix: The URL prefix to mount the application under. + """ + for methods, pattern, handler in subapp.url_map: + self.url_map.append( + (methods, URLPattern(url_prefix + pattern.url_pattern), + handler)) + for handler in subapp.before_request_handlers: + self.before_request_handlers.append(handler) + for handler in subapp.after_request_handlers: + self.after_request_handlers.append(handler) + for handler in subapp.after_error_request_handlers: + self.after_error_request_handlers.append(handler) + for status_code, handler in subapp.error_handlers.items(): + self.error_handlers[status_code] = handler + + @staticmethod + def abort(status_code, reason=None): + """Abort the current request and return an error response with the + given status code. + + :param status_code: The numeric status code of the response. + :param reason: The reason for the response, which is included in the + response body. + + Example:: + + from microdot import abort + + @app.route('/users/') + def get_user(id): + user = get_user_by_id(id) + if user is None: + abort(404) + return user.to_dict() + """ + raise HTTPException(status_code, reason) + + 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 import Microdot + + app = Microdot() + + @app.route('/') + async def index(request): + 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: # pragma: no cover + self.server = await asyncio.start_server(serve, host, port) + + while True: + try: + if hasattr(self.server, 'serve_forever'): # pragma: no cover + try: + await self.server.serve_forever() + except asyncio.CancelledError: + pass + 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 import Microdot + + app = Microdot() + + @app.route('/') + async def index(request): + return 'Hello, world!' + + app.run(debug=True) + """ + asyncio.run(self.start_server(host=host, port=port, debug=debug, + ssl=ssl)) # pragma: no cover + + def shutdown(self): + """Request a server shutdown. The server will then exit its request + listening loop and the :func:`run` function will return. This function + can be safely called from a route handler, as it only schedules the + server to terminate as soon as the request completes. + + Example:: + + @app.route('/shutdown') + def shutdown(request): + request.app.shutdown() + return 'The server is shutting down...' + """ + self.server.close() + + def find_route(self, req): + method = req.method.upper() + if method == 'OPTIONS' and self.options_handler: + return self.options_handler(req) + if method == 'HEAD': + method = 'GET' + f = 404 + for route_methods, route_pattern, route_handler in self.url_map: + req.url_args = route_pattern.match(req.path) + if req.url_args is not None: + if method in route_methods: + f = route_handler + break + else: + f = 405 + return f + + def default_options_handler(self, req): + allow = [] + for route_methods, route_pattern, route_handler in self.url_map: + if route_pattern.match(req.path) is not None: + allow.extend(route_methods) + if 'GET' in allow: + allow.append('HEAD') + allow.append('OPTIONS') + return {'Allow': ', '.join(allow)} + + 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 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 invoke_handler(handler, req) + if res: + break + if res is None: + res = await invoke_handler( + f, req, **req.url_args) + if isinstance(res, int): + res = '', res + if isinstance(res, tuple): + if isinstance(res[0], int): + res = ('', res[0], + res[1] if len(res) > 1 else {}) + 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 invoke_handler( + handler, req, res) or res + for handler in req.after_request_handlers: + res = await invoke_handler( + handler, req, res) or res + after_request_handled = True + elif isinstance(f, dict): + res = Response(headers=f) + elif f in self.error_handlers: + res = await 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 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 invoke_handler( + self.error_handlers[500], req) + else: + res = 'Internal server error', 500 + else: + if 400 in self.error_handlers: + res = await 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 invoke_handler( + handler, req, res) or res + res.is_head = (req and req.method == 'HEAD') + return res + + +Response.already_handled = Response() + +abort = Microdot.abort +redirect = Response.redirect +send_file = Response.send_file \ No newline at end of file diff --git a/esp32/lib/microdot/utemplate.py b/esp32/lib/microdot/utemplate.py new file mode 100644 index 0000000..16d0398 --- /dev/null +++ b/esp32/lib/microdot/utemplate.py @@ -0,0 +1,70 @@ +from utemplate import recompile + +_loader = None + + +class Template: + """A template object. + + :param template: The filename of the template to render, relative to the + configured template directory. + """ + @classmethod + def initialize(cls, template_dir='templates', + loader_class=recompile.Loader): + """Initialize the templating subsystem. + + :param template_dir: the directory where templates are stored. This + argument is optional. The default is to load + templates from a *templates* subdirectory. + :param loader_class: the ``utemplate.Loader`` class to use when loading + templates. This argument is optional. The default + is the ``recompile.Loader`` class, which + automatically recompiles templates when they + change. + """ + global _loader + _loader = loader_class(None, template_dir) + + def __init__(self, template): + if _loader is None: # pragma: no cover + self.initialize() + #: The name of the template + self.name = template + self.template = _loader.load(template) + + def generate(self, *args, **kwargs): + """Return a generator that renders the template in chunks, with the + given arguments.""" + return self.template(*args, **kwargs) + + def render(self, *args, **kwargs): + """Render the template with the given arguments and return it as a + string.""" + return ''.join(self.generate(*args, **kwargs)) + + def generate_async(self, *args, **kwargs): + """Return an asynchronous generator that renders the template in + chunks, using the given arguments.""" + class sync_to_async_iter(): + def __init__(self, iter): + self.iter = iter + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.iter) + except StopIteration: + raise StopAsyncIteration + + return sync_to_async_iter(self.generate(*args, **kwargs)) + + async def render_async(self, *args, **kwargs): + """Render the template with the given arguments asynchronously and + return it as a string.""" + response = '' + async for chunk in self.generate_async(*args, **kwargs): + response += chunk + return response diff --git a/esp32/lib/microdot/websocket.py b/esp32/lib/microdot/websocket.py new file mode 100644 index 0000000..0fb6f7c --- /dev/null +++ b/esp32/lib/microdot/websocket.py @@ -0,0 +1,231 @@ +import binascii +import hashlib +from microdot import Request, Response +from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception +from microdot.helpers import wraps + + +class WebSocketError(Exception): + """Exception raised when an error occurs in a WebSocket connection.""" + pass + + +class WebSocket: + """A WebSocket connection object. + + An instance of this class is sent to handler functions to manage the + WebSocket connection. + """ + CONT = 0 + TEXT = 1 + BINARY = 2 + CLOSE = 8 + PING = 9 + PONG = 10 + + #: Specify the maximum message size that can be received when calling the + #: ``receive()`` method. Messages with payloads that are larger than this + #: size will be rejected and the connection closed. Set to 0 to disable + #: the size check (be aware of potential security issues if you do this), + #: or to -1 to use the value set in + #: ``Request.max_body_length``. The default is -1. + #: + #: Example:: + #: + #: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages + max_message_length = -1 + + def __init__(self, request): + self.request = request + self.closed = False + + async def handshake(self): + response = self._handshake_response() + await self.request.sock[1].awrite( + b'HTTP/1.1 101 Switching Protocols\r\n') + await self.request.sock[1].awrite(b'Upgrade: websocket\r\n') + await self.request.sock[1].awrite(b'Connection: Upgrade\r\n') + await self.request.sock[1].awrite( + b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n') + + async def receive(self): + """Receive a message from the client.""" + while True: + opcode, payload = await self._read_frame() + send_opcode, data = self._process_websocket_frame(opcode, payload) + if send_opcode: # pragma: no cover + await self.send(data, send_opcode) + elif data: # pragma: no branch + return data + + async def send(self, data, opcode=None): + """Send a message to the client. + + :param data: the data to send, given as a string or bytes. + :param opcode: a custom frame opcode to use. If not given, the opcode + is ``TEXT`` or ``BINARY`` depending on the type of the + data. + """ + frame = self._encode_websocket_frame( + opcode or (self.TEXT if isinstance(data, str) else self.BINARY), + data) + await self.request.sock[1].awrite(frame) + + async def close(self): + """Close the websocket connection.""" + if not self.closed: # pragma: no cover + self.closed = True + await self.send(b'', self.CLOSE) + + def _handshake_response(self): + connection = False + upgrade = False + websocket_key = None + for header, value in self.request.headers.items(): + h = header.lower() + if h == 'connection': + connection = True + if 'upgrade' not in value.lower(): + return self.request.app.abort(400) + elif h == 'upgrade': + upgrade = True + if not value.lower() == 'websocket': + return self.request.app.abort(400) + elif h == 'sec-websocket-key': + websocket_key = value + if not connection or not upgrade or not websocket_key: + return self.request.app.abort(400) + d = hashlib.sha1(websocket_key.encode()) + d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11') + return binascii.b2a_base64(d.digest())[:-1] + + @classmethod + def _parse_frame_header(cls, header): + fin = header[0] & 0x80 + opcode = header[0] & 0x0f + if fin == 0 or opcode == cls.CONT: # pragma: no cover + raise WebSocketError('Continuation frames not supported') + has_mask = header[1] & 0x80 + length = header[1] & 0x7f + if length == 126: + length = -2 + elif length == 127: + length = -8 + return fin, opcode, has_mask, length + + def _process_websocket_frame(self, opcode, payload): + if opcode == self.TEXT: + payload = payload.decode() + elif opcode == self.BINARY: + pass + elif opcode == self.CLOSE: + raise WebSocketError('Websocket connection closed') + elif opcode == self.PING: + return self.PONG, payload + elif opcode == self.PONG: # pragma: no branch + return None, None + return None, payload + + @classmethod + def _encode_websocket_frame(cls, opcode, payload): + frame = bytearray() + frame.append(0x80 | opcode) + if opcode == cls.TEXT: + payload = payload.encode() + if len(payload) < 126: + frame.append(len(payload)) + elif len(payload) < (1 << 16): + frame.append(126) + frame.extend(len(payload).to_bytes(2, 'big')) + else: + frame.append(127) + frame.extend(len(payload).to_bytes(8, 'big')) + frame.extend(payload) + return frame + + async def _read_frame(self): + header = await self.request.sock[0].read(2) + if len(header) != 2: # pragma: no cover + raise WebSocketError('Websocket connection closed') + fin, opcode, has_mask, length = self._parse_frame_header(header) + if length == -2: + length = await self.request.sock[0].read(2) + length = int.from_bytes(length, 'big') + elif length == -8: + length = await self.request.sock[0].read(8) + length = int.from_bytes(length, 'big') + max_allowed_length = Request.max_body_length \ + if self.max_message_length == -1 else self.max_message_length + if length > max_allowed_length: + raise WebSocketError('Message too large') + if has_mask: # pragma: no cover + mask = await self.request.sock[0].read(4) + payload = await self.request.sock[0].read(length) + if has_mask: # pragma: no cover + payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload)) + return opcode, payload + + +async def websocket_upgrade(request): + """Upgrade a request handler to a websocket connection. + + This function can be called directly inside a route function to process a + WebSocket upgrade handshake, for example after the user's credentials are + verified. The function returns the websocket object:: + + @app.route('/echo') + async def echo(request): + if not authenticate_user(request): + abort(401) + ws = await websocket_upgrade(request) + while True: + message = await ws.receive() + await ws.send(message) + """ + ws = WebSocket(request) + await ws.handshake() + + @request.after_request + async def after_request(request, response): + return Response.already_handled + + return ws + + +def websocket_wrapper(f, upgrade_function): + @wraps(f) + async def wrapper(request, *args, **kwargs): + ws = await upgrade_function(request) + try: + await f(request, ws, *args, **kwargs) + except OSError as exc: + if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover + raise + except WebSocketError: + pass + except Exception as exc: + print_exception(exc) + finally: # pragma: no cover + try: + await ws.close() + except Exception: + pass + return Response.already_handled + return wrapper + + +def with_websocket(f): + """Decorator to make a route a WebSocket endpoint. + + This decorator is used to define a route that accepts websocket + connections. The route then receives a websocket object as a second + argument that it can use to send and receive messages:: + + @app.route('/echo') + @with_websocket + async def echo(request, ws): + while True: + message = await ws.receive() + await ws.send(message) + """ + return websocket_wrapper(f, websocket_upgrade) diff --git a/esp32/lib/utemplate/__init__.py b/esp32/lib/utemplate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/esp32/lib/utemplate/compiled.py b/esp32/lib/utemplate/compiled.py new file mode 100644 index 0000000..006e6f5 --- /dev/null +++ b/esp32/lib/utemplate/compiled.py @@ -0,0 +1,14 @@ +class Loader: + + def __init__(self, pkg, dir): + if dir == ".": + dir = "" + else: + dir = dir.replace("/", ".") + "." + if pkg and pkg != "__main__": + dir = pkg + "." + dir + self.p = dir + + def load(self, name): + name = name.replace(".", "_") + return __import__(self.p + name, None, None, (name,)).render \ No newline at end of file diff --git a/esp32/lib/utemplate/recompile.py b/esp32/lib/utemplate/recompile.py new file mode 100644 index 0000000..b9bae4e --- /dev/null +++ b/esp32/lib/utemplate/recompile.py @@ -0,0 +1,21 @@ +# (c) 2014-2020 Paul Sokolovsky. MIT license. +try: + from uos import stat, remove +except: + from os import stat, remove +from . import source + + +class Loader(source.Loader): + + def load(self, name): + o_path = self.pkg_path + self.compiled_path(name) + i_path = self.pkg_path + self.dir + "/" + name + try: + o_stat = stat(o_path) + i_stat = stat(i_path) + if i_stat[8] > o_stat[8]: + # input file is newer, remove output to force recompile + remove(o_path) + finally: + return super().load(name) \ No newline at end of file diff --git a/esp32/lib/utemplate/source.py b/esp32/lib/utemplate/source.py new file mode 100644 index 0000000..0ff4651 --- /dev/null +++ b/esp32/lib/utemplate/source.py @@ -0,0 +1,188 @@ +# (c) 2014-2019 Paul Sokolovsky. MIT license. +from . import compiled + + +class Compiler: + + START_CHAR = "{" + STMNT = "%" + STMNT_END = "%}" + EXPR = "{" + EXPR_END = "}}" + + def __init__(self, file_in, file_out, indent=0, seq=0, loader=None): + self.file_in = file_in + self.file_out = file_out + self.loader = loader + self.seq = seq + self._indent = indent + self.stack = [] + self.in_literal = False + self.flushed_header = False + self.args = "*a, **d" + + def indent(self, adjust=0): + if not self.flushed_header: + self.flushed_header = True + self.indent() + self.file_out.write("def render%s(%s):\n" % (str(self.seq) if self.seq else "", self.args)) + self.stack.append("def") + self.file_out.write(" " * (len(self.stack) + self._indent + adjust)) + + def literal(self, s): + if not s: + return + if not self.in_literal: + self.indent() + self.file_out.write('yield """') + self.in_literal = True + self.file_out.write(s.replace('"', '\\"')) + + def close_literal(self): + if self.in_literal: + self.file_out.write('"""\n') + self.in_literal = False + + def render_expr(self, e): + self.indent() + self.file_out.write('yield str(' + e + ')\n') + + def parse_statement(self, stmt): + tokens = stmt.split(None, 1) + if tokens[0] == "args": + if len(tokens) > 1: + self.args = tokens[1] + else: + self.args = "" + elif tokens[0] == "set": + self.indent() + self.file_out.write(stmt[3:].strip() + "\n") + elif tokens[0] == "include": + if not self.flushed_header: + # If there was no other output, we still need a header now + self.indent() + tokens = tokens[1].split(None, 1) + args = "" + if len(tokens) > 1: + args = tokens[1] + if tokens[0][0] == "{": + self.indent() + # "1" as fromlist param is uPy hack + self.file_out.write('_ = __import__(%s.replace(".", "_"), None, None, 1)\n' % tokens[0][2:-2]) + self.indent() + self.file_out.write("yield from _.render(%s)\n" % args) + return + + with self.loader.input_open(tokens[0][1:-1]) as inc: + self.seq += 1 + c = Compiler(inc, self.file_out, len(self.stack) + self._indent, self.seq) + inc_id = self.seq + self.seq = c.compile() + self.indent() + self.file_out.write("yield from render%d(%s)\n" % (inc_id, args)) + elif len(tokens) > 1: + if tokens[0] == "elif": + assert self.stack[-1] == "if" + self.indent(-1) + self.file_out.write(stmt + ":\n") + else: + self.indent() + self.file_out.write(stmt + ":\n") + self.stack.append(tokens[0]) + else: + if stmt.startswith("end"): + assert self.stack[-1] == stmt[3:] + self.stack.pop(-1) + elif stmt == "else": + assert self.stack[-1] == "if" + self.indent(-1) + self.file_out.write("else:\n") + else: + assert False + + def parse_line(self, l): + while l: + start = l.find(self.START_CHAR) + if start == -1: + self.literal(l) + return + self.literal(l[:start]) + self.close_literal() + sel = l[start + 1] + #print("*%s=%s=" % (sel, EXPR)) + if sel == self.STMNT: + end = l.find(self.STMNT_END) + assert end > 0 + stmt = l[start + len(self.START_CHAR + self.STMNT):end].strip() + self.parse_statement(stmt) + end += len(self.STMNT_END) + l = l[end:] + if not self.in_literal and l == "\n": + break + elif sel == self.EXPR: + # print("EXPR") + end = l.find(self.EXPR_END) + assert end > 0 + expr = l[start + len(self.START_CHAR + self.EXPR):end].strip() + self.render_expr(expr) + end += len(self.EXPR_END) + l = l[end:] + else: + self.literal(l[start]) + l = l[start + 1:] + + def header(self): + self.file_out.write("# Autogenerated file\n") + + def compile(self): + self.header() + for l in self.file_in: + self.parse_line(l) + self.close_literal() + return self.seq + + +class Loader(compiled.Loader): + + def __init__(self, pkg, dir): + super().__init__(pkg, dir) + self.dir = dir + if pkg == "__main__": + # if pkg isn't really a package, don't bother to use it + # it means we're running from "filesystem directory", not + # from a package. + pkg = None + + self.pkg_path = "" + if pkg: + p = __import__(pkg) + if isinstance(p.__path__, str): + # uPy + self.pkg_path = p.__path__ + else: + # CPy + self.pkg_path = p.__path__[0] + self.pkg_path += "/" + + def input_open(self, template): + path = self.pkg_path + self.dir + "/" + template + return open(path) + + def compiled_path(self, template): + return self.dir + "/" + template.replace(".", "_") + ".py" + + def load(self, name): + try: + return super().load(name) + except (OSError, ImportError): + pass + + compiled_path = self.pkg_path + self.compiled_path(name) + + f_in = self.input_open(name) + f_out = open(compiled_path, "w") + c = Compiler(f_in, f_out, loader=self) + c.compile() + f_in.close() + f_out.close() + return super().load(name) \ No newline at end of file diff --git a/esp32/src/boot.py b/esp32/src/boot.py new file mode 100644 index 0000000..39d15d5 --- /dev/null +++ b/esp32/src/boot.py @@ -0,0 +1,12 @@ +#create an accesstpoit called led-hoop with password hoop-1234 +#enable password protection + +import network + +ap_if = network.WLAN(network.AP_IF) +ap_mac = ap_if.config('mac') +ap_if.active(True) +ap_if.config(essid="led-hoop", password="hoop-1234") +ap_if.active(False) +ap_if.active(True) +print(ap_if.ifconfig()) \ No newline at end of file diff --git a/esp32/src/buttons.json b/esp32/src/buttons.json new file mode 100644 index 0000000..887ba7e --- /dev/null +++ b/esp32/src/buttons.json @@ -0,0 +1,58 @@ +{ + "buttons": [ + {"id": "start", "preset": "off"}, + {"id": "grab", "preset": "grab"}, + {"id": "spin1", "preset": "spin1"}, + {"id": "lift", "preset": "lift"}, + {"id": "flare", "preset": "flare"}, + {"id": "hook", "preset": "hook"}, + {"id": "roll1", "preset": "roll1"}, + {"id": "invertsplit", "preset": "invertsplit"}, + {"id": "pose1", "preset": "pose1"}, + {"id": "pose1", "preset": "pose2"}, + {"id": "roll2", "preset": "roll2"}, + {"id": "backbalance1", "preset": "backbalance1"}, + {"id": "beat1", "preset": "beat1"}, + {"id": "pose3", "preset": "pose3"}, + {"id": "roll3", "preset": "roll3"}, + {"id": "crouch", "preset": "crouch"}, + {"id": "pose4", "preset": "pose4"}, + {"id": "roll4", "preset": "roll4"}, + {"id": "backbendsplit", "preset": "backbendsplit"}, + {"id": "backbalance2", "preset": "backbalance2"}, + {"id": "backbalance3", "preset": "backbalance3"}, + {"id": "beat2", "preset": "beat2"}, + {"id": "straddle", "preset": "straddle"}, + {"id": "beat3", "preset": "beat3"}, + {"id": "frontbalance1", "preset": "frontbalance1"}, + {"id": "pose5", "preset": "pose5"}, + {"id": "pose6", "preset": "pose6"}, + {"id": "elbowhang", "preset": "elbowhang"}, + {"id": "elbowhangspin", "preset": "elbowhangspin"}, + {"id": "spin2", "preset": "spin2"}, + {"id": "dismount", "preset": "dismount"}, + {"id": "spin3", "preset": "spin3"}, + {"id": "fluff", "preset": "fluff"}, + {"id": "spin4", "preset": "spin4"}, + {"id": "flare2", "preset": "flare2"}, + {"id": "elbowhang", "preset": "elbowhang"}, + {"id": "elbowhangsplit2", "preset": "elbowhangsplit2"}, + {"id": "invert", "preset": "invert"}, + {"id": "roll5", "preset": "roll5"}, + {"id": "backbend", "preset": "backbend"}, + {"id": "pose7", "preset": "pose7"}, + {"id": "roll6", "preset": "roll6"}, + {"id": "seat", "preset": "seat"}, + {"id": "kneehang", "preset": "kneehang"}, + {"id": "legswoop", "preset": "legswoop"}, + {"id": "split", "preset": "split"}, + {"id": "foothang", "preset": "foothang"}, + {"id": "end", "preset": "end"} + ] +} + + + + + + \ No newline at end of file diff --git a/esp32/src/main.py b/esp32/src/main.py index 0ce75d3..74b8865 100644 --- a/esp32/src/main.py +++ b/esp32/src/main.py @@ -1,34 +1,109 @@ -""" -XIAO ESP32-C6: ESPNOW -> UART passthrough to Pico. -Receives messages via ESPNOW, forwards them unchanged to UART (GPIO17). -UART at 921600 baud. LED on GPIO15 blinks on activity. -""" -import network -import espnow -import machine -import time +from microdot import Microdot, send_file, Response +from microdot.utemplate import Template +from microdot.websocket import with_websocket +import json +from machine import Pin, UART, WDT +import asyncio -# UART: TX on GPIO17 -> Pico RX, max baud for throughput -UART_BAUD = 921600 -uart = machine.UART(1, baudrate=UART_BAUD, tx=17) -led = machine.Pin(15, machine.Pin.OUT) +# Load button config: {"buttons": [{"id": "...", "preset": "..."}, ...]} +def _load_buttons(): + try: + with open("buttons.json") as f: + raw = json.load(f) + return raw.get("buttons", []) + except (OSError, KeyError, ValueError): + return [] -# WLAN must be active for ESPNOW (no need to connect) -sta = network.WLAN(network.WLAN.IF_STA) -sta.active(True) -sta.disconnect() -e = espnow.ESPNow() -e.active(True) -# No peers needed to receive; add_peer() only for send() +def _save_buttons(buttons): + try: + with open("buttons.json", "w") as f: + json.dump({"buttons": buttons}, f) + return True + except OSError: + return False -# Recv timeout 0 = non-blocking -print("ESP32: ESPNOW -> UART passthrough, %d baud" % UART_BAUD) -while True: - mac, msg = e.irecv(0) - if msg: - uart.write(msg) - led.value(1) - else: - led.value(0) - time.sleep_ms(1) + +BUTTONS = _load_buttons() + +uart = UART(1, baudrate=921600, tx=Pin(16, Pin.OUT)) + +app = Microdot() +Response.default_content_type = 'text/html' + +# Device id used in select payload (e.g. Pico name) +DEVICE_ID = "1" + +# All connected WebSocket clients (for broadcasting button updates) +_ws_clients = set() + + +@app.route('/') +async def index_handler(request): + return Template('/index.html').render(buttons=BUTTONS, device_id=DEVICE_ID) + +@app.route("/api/buttons", methods=["GET"]) +async def api_get_buttons(request): + return {"buttons": BUTTONS} + + +@app.route("/api/buttons", methods=["POST"]) +async def api_save_buttons(request): + global BUTTONS + try: + data = request.json or {} + buttons = data.get("buttons", []) + if not isinstance(buttons, list): + return {"ok": False, "error": "buttons must be a list"}, 400 + if _save_buttons(buttons): + BUTTONS = buttons + return {"ok": True} + return {"ok": False, "error": "save failed"}, 500 + except Exception as e: + return {"ok": False, "error": str(e)}, 500 + + +@app.route("/static/") +async def static_handler(request, path): + if '..' in path: + # Directory traversal is not allowed + return 'Not found', 404 + return send_file('static/' + path) + +@app.route("/ws") +@with_websocket +async def ws(request, ws): + _ws_clients.add(ws) + print("WebSocket connection established") + + try: + while True: + data = await ws.receive() + if data: + # Forward WebSocket message to UART (line-delimited for Pico) + payload = data if isinstance(data, bytes) else data.encode("utf-8") + uart.write(payload + b"\n") + print(data) + + # Broadcast to all other clients so their UIs stay in sync + for other in list(_ws_clients): + if other is not ws and not other.closed: + try: + await other.send(data) + except Exception: + pass + else: + break + finally: + _ws_clients.discard(ws) + print("WebSocket connection closed") + + + +async def main(): + server = asyncio.create_task(app.start_server("0.0.0.0", 80)) + + await server + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/esp32/src/squence.txt b/esp32/src/squence.txt new file mode 100644 index 0000000..c37b767 --- /dev/null +++ b/esp32/src/squence.txt @@ -0,0 +1,48 @@ +start +grab +spin1 +lift +flare +hook +roll1 +invertsplit +pose1 +pose1 +roll2 +backbalance1 +beat1 +pose3 +roll3 +crouch +pose4 +roll4 +backbendsplit +backbalance2 +backbalance3 +beat2 +straddle +beat3 +frontbalance1 +pose5 +pose6 +elbowhang +elbowhangspin +spin2 +dismount +spin3 +fluff +spin4 +flare2 +elbowhang +elbowhangsplit2 +invert +roll5 +backbend +pose7 +roll6 +seat +kneehang +legswoop +split +foothang +end diff --git a/esp32/src/static/main.js b/esp32/src/static/main.js new file mode 100644 index 0000000..ccb9ba9 --- /dev/null +++ b/esp32/src/static/main.js @@ -0,0 +1,617 @@ +var deviceId = document.body.getAttribute('data-device-id') || '1'; +var ws = null; +var currentEditIndex = -1; // -1 means "new button" + +function getButtonsFromDom() { + var btns = document.querySelectorAll('#buttonsContainer .btn'); + return Array.prototype.map.call(btns, function (el) { + var obj = { + id: el.getAttribute('data-id') || el.textContent.trim(), + preset: el.getAttribute('data-preset') || '' + }; + var p = el.getAttribute('data-p'); + if (p) obj.p = p; + var d = el.getAttribute('data-d'); + if (d !== null && d !== '') obj.d = parseInt(d, 10) || 0; + var b = el.getAttribute('data-b'); + if (b !== null && b !== '') obj.b = parseInt(b, 10) || 0; + var c = el.getAttribute('data-c'); + if (c) { + try { + var parsed = JSON.parse(c); + if (Array.isArray(parsed)) obj.c = parsed; + } catch (e) {} + } + for (var i = 1; i <= 8; i++) { + var key = 'n' + i; + var v = el.getAttribute('data-' + key); + if (v !== null && v !== '') { + obj[key] = parseInt(v, 10) || 0; + } + } + return obj; + }); +} + +function renderButtons(buttons) { + var container = document.getElementById('buttonsContainer'); + container.innerHTML = ''; + buttons.forEach(function (btn, idx) { + var el = document.createElement('button'); + el.className = 'btn'; + el.type = 'button'; + el.setAttribute('data-preset', btn.preset); + el.setAttribute('data-id', btn.id); + el.setAttribute('data-index', String(idx)); + // Optional preset config stored per button + if (btn.p !== undefined) el.setAttribute('data-p', btn.p); + if (btn.d !== undefined) el.setAttribute('data-d', String(btn.d)); + if (btn.b !== undefined) el.setAttribute('data-b', String(btn.b)); + if (btn.c !== undefined) { + try { + el.setAttribute('data-c', JSON.stringify(btn.c)); + } catch (e) {} + } + for (var i = 1; i <= 8; i++) { + var key = 'n' + i; + if (btn[key] !== undefined) { + el.setAttribute('data-' + key, String(btn[key])); + } + } + el.draggable = true; + el.textContent = btn.id; + container.appendChild(el); + }); + attachButtonListeners(); +} + +function attachButtonListeners() { + var container = document.getElementById('buttonsContainer'); + if (!container) return; + var btns = container.querySelectorAll('.btn'); + for (var i = 0; i < btns.length; i++) { + var el = btns[i]; + el.setAttribute('data-index', String(i)); + el.onclick = function(ev) { + if (longPressTriggered) { + ev.preventDefault(); + return; + } + var btn = ev.currentTarget; + sendSelect(btn.getAttribute('data-preset'), btn); + }; + el.oncontextmenu = function(ev) { + ev.preventDefault(); + showButtonContextMenu(ev, ev.currentTarget); + }; + (function(buttonEl) { + var startX, startY; + el.ontouchstart = function(ev) { + if (ev.touches.length !== 1) return; + longPressTriggered = false; + startX = ev.touches[0].clientX; + startY = ev.touches[0].clientY; + longPressTimer = setTimeout(function() { + longPressTimer = null; + longPressTriggered = true; + showButtonContextMenu({ clientX: startX, clientY: startY }, buttonEl); + }, 500); + }; + el.ontouchmove = function(ev) { + if (longPressTimer && ev.touches.length === 1) { + var dx = ev.touches[0].clientX - startX; + var dy = ev.touches[0].clientY - startY; + if (dx * dx + dy * dy > 100) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + } + }; + el.ontouchend = el.ontouchcancel = function() { + if (longPressTimer) { + clearTimeout(longPressTimer); + longPressTimer = null; + } + setTimeout(function() { longPressTriggered = false; }, 400); + }; + })(el); + el.ondragstart = function(ev) { + ev.dataTransfer.setData('text/plain', ev.currentTarget.getAttribute('data-index')); + ev.dataTransfer.effectAllowed = 'move'; + ev.currentTarget.classList.add('dragging'); + }; + el.ondragend = function(ev) { + ev.currentTarget.classList.remove('dragging'); + }; + el.ondragover = function(ev) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'move'; + var target = ev.currentTarget; + if (target.classList.contains('dragging')) return; + target.classList.add('drop-target'); + }; + el.ondragleave = function(ev) { + ev.currentTarget.classList.remove('drop-target'); + }; + el.ondrop = function(ev) { + ev.preventDefault(); + ev.currentTarget.classList.remove('drop-target'); + var fromIdx = parseInt(ev.dataTransfer.getData('text/plain'), 10); + var toIdx = parseInt(ev.currentTarget.getAttribute('data-index'), 10); + if (fromIdx === toIdx) return; + var buttons = getButtonsFromDom(); + var item = buttons.splice(fromIdx, 1)[0]; + buttons.splice(toIdx, 0, item); + renderButtons(buttons); + saveButtons(buttons); + }; + } +} + +// Button editor (per-button preset config) + +function saveButtons(buttons, callback) { + fetch('/api/buttons', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ buttons: buttons }) + }).then(function(r) { return r.json(); }).then(function(data) { + if (callback) callback(data); + else showToast(data.ok ? 'Saved' : (data.error || 'Save failed')); + }).catch(function() { + if (callback) callback({ ok: false }); + else showToast('Save failed'); + }); +} + +function saveCurrentButtons() { + var buttons = getButtonsFromDom(); + saveButtons(buttons, function(data) { + showToast(data.ok ? 'Saved' : (data.error || 'Save failed')); + }); +} + +var toastTimer = null; + +function showToast(message) { + var el = document.getElementById('toast'); + if (!el) { + el = document.createElement('div'); + el.id = 'toast'; + el.className = 'toast'; + document.body.appendChild(el); + } + el.textContent = message; + el.classList.add('toast-visible'); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(function() { + el.classList.remove('toast-visible'); + toastTimer = null; + }, 2000); +} + +function addButton(button) { + var buttons = getButtonsFromDom(); + buttons.push(button); + renderButtons(buttons); + saveButtons(buttons); +} + +var contextMenuEl = null; +var longPressTimer = null; +var longPressTriggered = false; + +function openNewButtonEditor() { + currentEditIndex = -1; + var title = document.getElementById('buttonEditorTitle'); + if (title) title.textContent = 'New button'; + fillButtonEditorFields({ + id: '', + preset: '', + p: 'off', + d: 0, + b: 0, + c: [[0, 0, 0]] + }); + openButtonEditor(); +} + +function openExistingButtonEditor(index) { + currentEditIndex = index; + var buttons = getButtonsFromDom(); + var btn = buttons[index]; + var title = document.getElementById('buttonEditorTitle'); + if (title) title.textContent = 'Edit button'; + fillButtonEditorFields(btn); + openButtonEditor(); +} + +function openButtonEditor() { + var editor = document.getElementById('buttonEditor'); + if (!editor) return; + editor.classList.add('open'); + document.body.classList.add('button-editor-open'); +} + +function closeButtonEditor() { + var editor = document.getElementById('buttonEditor'); + if (!editor) return; + editor.classList.remove('open'); + document.body.classList.remove('button-editor-open'); +} + +function fillButtonEditorFields(btn) { + document.getElementById('be-label').value = btn.id || ''; + document.getElementById('be-preset').value = btn.preset || btn.id || ''; + var pattern = btn.p || btn.preset || 'off'; + var patternSelect = document.getElementById('be-pattern'); + if (patternSelect) { + patternSelect.value = pattern; + } + document.getElementById('be-delay').value = btn.d != null ? String(btn.d) : ''; + document.getElementById('be-brightness').value = btn.b != null ? String(btn.b) : ''; + var colors = btn.c; + var colorsStr = ''; + if (Array.isArray(colors) && colors.length) { + colorsStr = colors.map(function (rgb) { + return (rgb[0] || 0) + ',' + (rgb[1] || 0) + ',' + (rgb[2] || 0); + }).join('; '); + } + document.getElementById('be-colors').value = colorsStr; + for (var i = 1; i <= 8; i++) { + var key = 'n' + i; + var el = document.getElementById('be-' + key); + if (el) el.value = btn[key] != null ? String(btn[key]) : ''; + } +} + +function buildButtonFromEditor() { + function toInt(val, fallback) { + var n = parseInt(val, 10); + return isNaN(n) ? fallback : n; + } + + var label = (document.getElementById('be-label').value || '').trim(); + var presetName = (document.getElementById('be-preset').value || '').trim() || label || 'preset'; + var patternEl = document.getElementById('be-pattern'); + var pattern = patternEl ? (patternEl.value || '').trim() : 'off'; + var delayVal = document.getElementById('be-delay').value; + var brightVal = document.getElementById('be-brightness').value; + var colorsRaw = (document.getElementById('be-colors').value || '').trim(); + + var d = toInt(delayVal, 0); + var b = toInt(brightVal, 0); + + var colors = []; + if (colorsRaw) { + colorsRaw.split(';').forEach(function (chunk) { + var parts = chunk.split(',').map(function (s) { return s.trim(); }).filter(Boolean); + if (parts.length === 3) { + var r = toInt(parts[0], 0); + var g = toInt(parts[1], 0); + var bl = toInt(parts[2], 0); + r = Math.max(0, Math.min(255, r)); + g = Math.max(0, Math.min(255, g)); + bl = Math.max(0, Math.min(255, bl)); + colors.push([r, g, bl]); + } + }); + } + if (!colors.length) colors = [[0, 0, 0]]; + + var btn = { + id: label || presetName, + preset: presetName, + p: pattern, + d: d, + b: b, + c: colors + }; + + for (var i = 1; i <= 8; i++) { + var key = 'n' + i; + var el = document.getElementById('be-' + key); + if (!el) continue; + var v = el.value; + if (v !== '') { + btn[key] = toInt(v, 0); + } + } + + return btn; +} + +function saveButtonFromEditor() { + var btn = buildButtonFromEditor(); + var buttons = getButtonsFromDom(); + if (currentEditIndex >= 0 && currentEditIndex < buttons.length) { + buttons[currentEditIndex] = btn; + } else { + buttons.push(btn); + } + renderButtons(buttons); + saveButtons(buttons); + + // Also send this preset to the Pico and save it there + if (ws && ws.readyState === WebSocket.OPEN && btn.preset) { + var presetData = { + p: btn.p, + d: btn.d, + b: btn.b, + c: btn.c + }; + for (var i = 1; i <= 8; i++) { + var key = 'n' + i; + if (btn[key] !== undefined) { + presetData[key] = btn[key]; + } + } + ws.send(JSON.stringify({ + preset_edit: { + name: btn.preset, + data: presetData + } + })); + ws.send(JSON.stringify({ preset_save: true })); + } + + closeButtonEditor(); +} + +function showButtonContextMenu(evOrCoords, buttonEl) { + hideContextMenu(); + var x = evOrCoords.clientX != null ? evOrCoords.clientX : evOrCoords.x; + var y = evOrCoords.clientY != null ? evOrCoords.clientY : evOrCoords.y; + var buttons = getButtonsFromDom(); + var idx = parseInt(buttonEl.getAttribute('data-index'), 10); + var btn = buttons[idx]; + contextMenuEl = document.createElement('div'); + contextMenuEl.className = 'context-menu'; + contextMenuEl.style.left = x + 'px'; + contextMenuEl.style.top = y + 'px'; + contextMenuEl.innerHTML = ''; + document.body.appendChild(contextMenuEl); + document.addEventListener('click', hideContextMenuOnce); + contextMenuEl.querySelector('[data-action="edit"]').onclick = function() { + hideContextMenu(); + openExistingButtonEditor(idx); + }; + contextMenuEl.querySelector('[data-action="delete"]').onclick = function() { + hideContextMenu(); + buttons.splice(idx, 1); + renderButtons(buttons); + saveButtons(buttons); + }; +} + +function hideContextMenu() { + if (contextMenuEl && contextMenuEl.parentNode) contextMenuEl.parentNode.removeChild(contextMenuEl); + contextMenuEl = null; + document.removeEventListener('click', hideContextMenuOnce); +} + +function hideContextMenuOnce() { + hideContextMenu(); +} + +function connect() { + var proto = location.protocol === "https:" ? "wss:" : "ws:"; + ws = new WebSocket(proto + "//" + location.host + "/ws"); + ws.onclose = function() { setTimeout(connect, 2000); }; + ws.onmessage = function(ev) { + try { + var msg = JSON.parse(ev.data); + if (msg && msg.select != null) { + var preset = typeof msg.select === 'string' ? msg.select : (Array.isArray(msg.select) ? msg.select[1] : null); + if (preset != null) { + document.querySelectorAll('.buttons .btn').forEach(function(b) { b.classList.remove('selected'); }); + if (preset !== 'off') { + var el = document.querySelector('.buttons .btn[data-preset="' + preset + '"]'); + if (el) { + el.classList.add('selected'); + scrollSelectedIntoView(); + updateSelectedObserver(); + } + } + } + } + } catch (e) {} + }; +} + +function sendSelect(preset, el) { + var msg = JSON.stringify({ select: preset }); + if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg); + document.querySelectorAll('.buttons .btn').forEach(function(b) { b.classList.remove('selected'); }); + if (el) el.classList.add('selected'); + updateSelectedObserver(); +} + +function togglePresetEditor() { + var editor = document.getElementById('presetEditor'); + if (!editor) return; + var isOpen = editor.classList.contains('open'); + if (isOpen) { + editor.classList.remove('open'); + document.body.classList.remove('preset-editor-open'); + } else { + editor.classList.add('open'); + document.body.classList.add('preset-editor-open'); + } +} + +function buildPresetPayloadFromForm() { + var name = (document.getElementById('pe-name').value || '').trim(); + var patternEl = document.getElementById('pe-pattern'); + var pattern = patternEl ? (patternEl.value || '').trim() : 'off'; + var delayVal = document.getElementById('pe-delay').value; + var brightVal = document.getElementById('pe-brightness').value; + var colorsRaw = (document.getElementById('pe-colors').value || '').trim(); + + function toInt(val, fallback) { + var n = parseInt(val, 10); + return isNaN(n) ? fallback : n; + } + + var d = toInt(delayVal, 0); + var b = toInt(brightVal, 0); + + var colors = []; + if (colorsRaw) { + colorsRaw.split(';').forEach(function (chunk) { + var parts = chunk.split(',').map(function (s) { return s.trim(); }).filter(Boolean); + if (parts.length === 3) { + var r = toInt(parts[0], 0); + var g = toInt(parts[1], 0); + var bl = toInt(parts[2], 0); + r = Math.max(0, Math.min(255, r)); + g = Math.max(0, Math.min(255, g)); + bl = Math.max(0, Math.min(255, bl)); + colors.push([r, g, bl]); + } + }); + } + if (!colors.length) colors = [[0, 0, 0]]; + + var data = { p: pattern, d: d, b: b, c: colors }; + + ['n1', 'n2', 'n3', 'n4', 'n5', 'n6', 'n7', 'n8'].forEach(function (key) { + var el = document.getElementById('pe-' + key); + if (!el) return; + var v = el.value; + if (v !== '') { + data[key] = toInt(v, 0); + } + }); + + return { name: name, data: data }; +} + +function sendPresetToPico() { + var payload = buildPresetPayloadFromForm(); + if (!payload.name) { + showToast('Preset name is required'); + return; + } + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ preset_edit: payload })); + ensurePresetInList(payload.name); + showToast('Preset sent (not yet saved)'); + } else { + showToast('Not connected'); + } +} + +function savePresetsOnPico() { + var payload = buildPresetPayloadFromForm(); + if (!payload.name) { + showToast('Preset name is required'); + return; + } + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ preset_edit: payload })); + ws.send(JSON.stringify({ preset_save: true })); + ensurePresetInList(payload.name); + showToast('Preset saved to Pico'); + } else { + showToast('Not connected'); + } +} + +function deletePresetOnPico() { + var name = (document.getElementById('pe-name').value || '').trim(); + if (!name) { + showToast('Preset name is required'); + return; + } + var msg = { preset_delete: name }; + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)); + removePresetFromList(name); + ws.send(JSON.stringify(msg)); + showToast('Delete command sent'); + } else { + showToast('Not connected'); + } +} + +function toggleMenu() { + var menu = document.getElementById('menu'); + menu.classList.toggle('open'); +} + +function closeMenu() { + document.getElementById('menu').classList.remove('open'); +} + +document.addEventListener('click', function(ev) { + var menu = document.getElementById('menu'); + var menuBtn = document.querySelector('.menu-btn'); + if (menu.classList.contains('open') && menuBtn && !menu.contains(ev.target) && !menuBtn.contains(ev.target)) { + closeMenu(); + } +}); + +function scrollSelectedIntoView() { + var el = document.querySelector('.buttons .btn.selected'); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); +} + +var scrollObserver = null; +var observedSelectedEl = null; + +function setupScrollObserver() { + var container = document.getElementById('buttonsContainer'); + if (!container || scrollObserver) return; + scrollObserver = new IntersectionObserver( + function (entries) { + entries.forEach(function (entry) { + if (entry.intersectionRatio < 0.5 && entry.target.classList.contains('selected')) { + scrollSelectedIntoView(); + } + }); + }, + { root: container, rootMargin: '0px', threshold: [0, 0.25, 0.5, 0.75, 1] } + ); +} + +function updateSelectedObserver() { + var el = document.querySelector('.buttons .btn.selected'); + if (el === observedSelectedEl) return; + if (scrollObserver) { + if (observedSelectedEl) scrollObserver.unobserve(observedSelectedEl); + observedSelectedEl = el; + if (el) { + setupScrollObserver(); + scrollObserver.observe(el); + } + } +} + +function nextPreset() { + var btns = document.querySelectorAll('.buttons .btn'); + if (btns.length === 0) return; + var idx = -1; + for (var i = 0; i < btns.length; i++) { + if (btns[i].classList.contains('selected')) { idx = i; break; } + } + idx = (idx + 1) % btns.length; + var nextEl = btns[idx]; + sendSelect(nextEl.getAttribute('data-preset'), nextEl); + scrollSelectedIntoView(); +} + +setupScrollObserver(); +// Re-render buttons from server config (including per-button presets) once loaded. +fetch('/api/buttons') + .then(function (r) { return r.json(); }) + .then(function (data) { + var buttons = Array.isArray(data.buttons) ? data.buttons : getButtonsFromDom(); + renderButtons(buttons); + }) + .catch(function () { + // Fallback: use buttons rendered by the template + renderButtons(getButtonsFromDom()); + }); +updateSelectedObserver(); +connect(); diff --git a/esp32/src/static/styles.css b/esp32/src/static/styles.css new file mode 100644 index 0000000..3ea7810 --- /dev/null +++ b/esp32/src/static/styles.css @@ -0,0 +1,256 @@ +body { font-family: sans-serif; margin: 0; padding: 0 0 6.5rem 0; } + +body.button-editor-open { + overflow: hidden; +} + +.header { position: relative; } + +.menu-btn { + width: 100%; + padding: 0.75rem 1rem; + font-size: 1rem; + cursor: pointer; + border: 1px solid #555; + border-radius: 0; + background: #333; + color: #fff; + text-align: left; +} + +.menu { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 10; + flex-direction: column; + border: 1px solid #555; + border-top: none; + background: #2a2a2a; + box-shadow: 0 4px 8px rgba(0,0,0,0.3); +} + +.menu.open { display: flex; } + +.menu-item { + padding: 0.75rem 1rem; + font-size: 1rem; + cursor: pointer; + border: none; + border-bottom: 1px solid #444; + background: transparent; + color: #fff; + text-align: left; +} + +.menu-item:last-child { border-bottom: none; } + +.menu-item:hover { background: #444; } + +.menu-item.off { background: #522; } + +.buttons { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1px; + max-height: calc(100vh - 10rem); + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +@media (min-width: 768px) { + .buttons { + grid-template-columns: repeat(6, 1fr); + } +} +.btn { + padding: 0.5rem 0.25rem; + border: 1px solid #555; + border-radius: 0; + background: #444; + color: #fff; + font-size: 1rem; + cursor: pointer; + min-height: 4.15rem; +} +.btn.selected { + background: #2a7; + border-color: #3b8; +} + +.btn.dragging { opacity: 0.5; } + +.btn.drop-target { outline: 2px solid #3b8; outline-offset: -2px; } + +.context-menu { + position: fixed; + z-index: 100; + background: #2a2a2a; + border: 1px solid #555; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + min-width: 8rem; +} + +.context-menu-item { + display: block; + width: 100%; + padding: 0.5rem 1rem; + border: none; + background: transparent; + color: #fff; + font-size: 1rem; + text-align: left; + cursor: pointer; +} + +.context-menu-item:hover { background: #444; } + +.toast { + position: fixed; + bottom: 5rem; + left: 50%; + transform: translateX(-50%) translateY(2rem); + padding: 0.5rem 1rem; + background: #333; + color: #fff; + border-radius: 4px; + font-size: 0.9rem; + z-index: 1000; + opacity: 0; + transition: opacity 0.2s, transform 0.2s; + pointer-events: none; +} + +.toast.toast-visible { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +.next-btn { + position: fixed; + bottom: 0; + left: 0; + right: 0; + width: 100%; + padding: 2rem 1rem; + padding-bottom: max(2rem, env(safe-area-inset-bottom)); + font-size: 1.25rem; + cursor: pointer; + border: 2px solid #555; + border-radius: 0; + background: #333; + color: #fff; +} + +.preset-editor { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + display: none; + align-items: center; + justify-content: center; + z-index: 200; + padding: 1rem; +} + +.preset-editor.open { + display: flex; +} + +.preset-editor-inner { + width: 100%; + max-width: 28rem; + max-height: calc(100vh - 4rem); + overflow-y: auto; + background: #222; + border: 1px solid #555; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.6); + border-radius: 6px; + padding: 1rem 1.25rem 0.75rem; + box-sizing: border-box; +} + +.preset-editor-title { + margin: 0 0 0.75rem; + font-size: 1.25rem; + color: #fff; +} + +.preset-editor-field { + display: flex; + flex-direction: column; + margin-bottom: 0.5rem; + gap: 0.25rem; + font-size: 0.9rem; + color: #ddd; +} + +.preset-editor-field span { + opacity: 0.85; +} + +.preset-editor-field input { + padding: 0.35rem 0.5rem; + border-radius: 4px; + border: 1px solid #555; + background: #111; + color: #fff; + font-size: 0.95rem; +} + +.preset-editor-row { + display: flex; + gap: 0.5rem; +} + +.preset-editor-field.small { + flex: 0 0 48%; +} + +.preset-editor-field.small input { + padding: 0.25rem 0.4rem; + font-size: 0.85rem; +} + +.preset-editor-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0.75rem 0 0.5rem; +} + +.preset-editor-btn { + flex: 1; + padding: 0.5rem 0.75rem; + border-radius: 4px; + border: 1px solid #555; + background: #333; + color: #fff; + font-size: 0.95rem; + cursor: pointer; +} + +.preset-editor-btn.primary { + background: #2a7; + border-color: #3b8; +} + +.preset-editor-btn.danger { + background: #722; + border-color: #a33; +} + +.preset-editor-close { + width: 100%; + margin-top: 0.25rem; + padding: 0.4rem 0.75rem; + border-radius: 4px; + border: 1px solid #555; + background: #111; + color: #ccc; + font-size: 0.9rem; + cursor: pointer; +} + +/* preset-list removed with old preset editor */ diff --git a/esp32/src/templates/index.html b/esp32/src/templates/index.html new file mode 100644 index 0000000..a6931a8 --- /dev/null +++ b/esp32/src/templates/index.html @@ -0,0 +1,145 @@ +{% args buttons, device_id %} + + + + + + Led Hoop + + + +
+ + +
+
+ {% for btn in buttons %} + + {% endfor %} +
+ +
+
+

Button

+ + + +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +