Add ESP32 web control and UART bridge.
Replace ESPNOW passthrough with a Microdot-based web UI and WebSocket-to-UART bridge for preset selection. Made-with: Cursor
This commit is contained in:
2
esp32/lib/microdot/__init__.py
Normal file
2
esp32/lib/microdot/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
|
||||||
|
send_file # noqa: F401
|
||||||
8
esp32/lib/microdot/helpers.py
Normal file
8
esp32/lib/microdot/helpers.py
Normal file
@@ -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 _
|
||||||
1450
esp32/lib/microdot/microdot.py
Normal file
1450
esp32/lib/microdot/microdot.py
Normal file
File diff suppressed because it is too large
Load Diff
70
esp32/lib/microdot/utemplate.py
Normal file
70
esp32/lib/microdot/utemplate.py
Normal file
@@ -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
|
||||||
231
esp32/lib/microdot/websocket.py
Normal file
231
esp32/lib/microdot/websocket.py
Normal file
@@ -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)
|
||||||
0
esp32/lib/utemplate/__init__.py
Normal file
0
esp32/lib/utemplate/__init__.py
Normal file
14
esp32/lib/utemplate/compiled.py
Normal file
14
esp32/lib/utemplate/compiled.py
Normal file
@@ -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
|
||||||
21
esp32/lib/utemplate/recompile.py
Normal file
21
esp32/lib/utemplate/recompile.py
Normal file
@@ -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)
|
||||||
188
esp32/lib/utemplate/source.py
Normal file
188
esp32/lib/utemplate/source.py
Normal file
@@ -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)
|
||||||
12
esp32/src/boot.py
Normal file
12
esp32/src/boot.py
Normal file
@@ -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())
|
||||||
58
esp32/src/buttons.json
Normal file
58
esp32/src/buttons.json
Normal file
@@ -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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,34 +1,109 @@
|
|||||||
"""
|
from microdot import Microdot, send_file, Response
|
||||||
XIAO ESP32-C6: ESPNOW -> UART passthrough to Pico.
|
from microdot.utemplate import Template
|
||||||
Receives messages via ESPNOW, forwards them unchanged to UART (GPIO17).
|
from microdot.websocket import with_websocket
|
||||||
UART at 921600 baud. LED on GPIO15 blinks on activity.
|
import json
|
||||||
"""
|
from machine import Pin, UART, WDT
|
||||||
import network
|
import asyncio
|
||||||
import espnow
|
|
||||||
import machine
|
|
||||||
import time
|
|
||||||
|
|
||||||
# UART: TX on GPIO17 -> Pico RX, max baud for throughput
|
# Load button config: {"buttons": [{"id": "...", "preset": "..."}, ...]}
|
||||||
UART_BAUD = 921600
|
def _load_buttons():
|
||||||
uart = machine.UART(1, baudrate=UART_BAUD, tx=17)
|
try:
|
||||||
led = machine.Pin(15, machine.Pin.OUT)
|
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()
|
def _save_buttons(buttons):
|
||||||
e.active(True)
|
try:
|
||||||
# No peers needed to receive; add_peer() only for send()
|
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)
|
BUTTONS = _load_buttons()
|
||||||
while True:
|
|
||||||
mac, msg = e.irecv(0)
|
uart = UART(1, baudrate=921600, tx=Pin(16, Pin.OUT))
|
||||||
if msg:
|
|
||||||
uart.write(msg)
|
app = Microdot()
|
||||||
led.value(1)
|
Response.default_content_type = 'text/html'
|
||||||
else:
|
|
||||||
led.value(0)
|
# Device id used in select payload (e.g. Pico name)
|
||||||
time.sleep_ms(1)
|
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/<path:path>")
|
||||||
|
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())
|
||||||
48
esp32/src/squence.txt
Normal file
48
esp32/src/squence.txt
Normal file
@@ -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
|
||||||
617
esp32/src/static/main.js
Normal file
617
esp32/src/static/main.js
Normal file
@@ -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 = '<button type="button" class="context-menu-item" data-action="edit">Edit</button><button type="button" class="context-menu-item" data-action="delete">Delete</button>';
|
||||||
|
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();
|
||||||
256
esp32/src/static/styles.css
Normal file
256
esp32/src/static/styles.css
Normal file
@@ -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 */
|
||||||
145
esp32/src/templates/index.html
Normal file
145
esp32/src/templates/index.html
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
{% args buttons, device_id %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Led Hoop</title>
|
||||||
|
<link rel="stylesheet" href="static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body data-device-id="{{ device_id }}">
|
||||||
|
<div class="header">
|
||||||
|
<button class="menu-btn" type="button" onclick="toggleMenu()" aria-label="Menu">☰ Menu</button>
|
||||||
|
<div class="menu" id="menu">
|
||||||
|
<button class="menu-item off" type="button" onclick="sendSelect('off', null); closeMenu();">Off</button>
|
||||||
|
<button class="menu-item" type="button" onclick="sendSelect('test', null); closeMenu();">Test</button>
|
||||||
|
<button class="menu-item" type="button" onclick="sendSelect('calibration', null); closeMenu();">Calibration</button>
|
||||||
|
<button class="menu-item" type="button" onclick="closeMenu(); openNewButtonEditor();">Add button</button>
|
||||||
|
<button class="menu-item" type="button" onclick="closeMenu(); saveCurrentButtons();">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="buttons" id="buttonsContainer">
|
||||||
|
{% for btn in buttons %}
|
||||||
|
<button class="btn" type="button" data-preset="{{ btn['preset'] }}" data-id="{{ btn['id'] }}"
|
||||||
|
draggable="true">{{ btn['id'] }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preset-editor" id="buttonEditor">
|
||||||
|
<div class="preset-editor-inner">
|
||||||
|
<h2 class="preset-editor-title" id="buttonEditorTitle">Button</h2>
|
||||||
|
<label class="preset-editor-field">
|
||||||
|
<span>Button label</span>
|
||||||
|
<input id="be-label" type="text" placeholder="e.g. grab" />
|
||||||
|
</label>
|
||||||
|
<label class="preset-editor-field">
|
||||||
|
<span>Preset name</span>
|
||||||
|
<input id="be-preset" type="text" placeholder="e.g. grab" />
|
||||||
|
</label>
|
||||||
|
<label class="preset-editor-field">
|
||||||
|
<span>Pattern (p)</span>
|
||||||
|
<select id="be-pattern">
|
||||||
|
<option value="spin">spin</option>
|
||||||
|
<option value="roll">roll</option>
|
||||||
|
<option value="grab">grab</option>
|
||||||
|
<option value="lift">lift</option>
|
||||||
|
<option value="flare">flare</option>
|
||||||
|
<option value="hook">hook</option>
|
||||||
|
<option value="invertsplit">invertsplit</option>
|
||||||
|
<option value="pose">pose</option>
|
||||||
|
<option value="backbalance">backbalance</option>
|
||||||
|
<option value="beat">beat</option>
|
||||||
|
<option value="crouch">crouch</option>
|
||||||
|
<option value="backbendsplit">backbendsplit</option>
|
||||||
|
<option value="straddle">straddle</option>
|
||||||
|
<option value="frontbalance">frontbalance</option>
|
||||||
|
<option value="elbowhang">elbowhang</option>
|
||||||
|
<option value="elbowhangspin">elbowhangspin</option>
|
||||||
|
<option value="dismount">dismount</option>
|
||||||
|
<option value="fluff">fluff</option>
|
||||||
|
<option value="elbowhangsplit">elbowhangsplit</option>
|
||||||
|
<option value="invert">invert</option>
|
||||||
|
<option value="backbend">backbend</option>
|
||||||
|
<option value="seat">seat</option>
|
||||||
|
<option value="kneehang">kneehang</option>
|
||||||
|
<option value="legswoop">legswoop</option>
|
||||||
|
<option value="split">split</option>
|
||||||
|
<option value="foothang">foothang</option>
|
||||||
|
<option value="point">point</option>
|
||||||
|
<option value="off">off</option>
|
||||||
|
<option value="on">on</option>
|
||||||
|
<option value="blink">blink</option>
|
||||||
|
<option value="rainbow">rainbow</option>
|
||||||
|
<option value="pulse">pulse</option>
|
||||||
|
<option value="transition">transition</option>
|
||||||
|
<option value="chase">chase</option>
|
||||||
|
<option value="circle">circle</option>
|
||||||
|
<option value="calibration">calibration</option>
|
||||||
|
<option value="test">test</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="preset-editor-row">
|
||||||
|
<label class="preset-editor-field">
|
||||||
|
<span>Delay (d)</span>
|
||||||
|
<input id="be-delay" type="number" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="preset-editor-field">
|
||||||
|
<span>Brightness (b)</span>
|
||||||
|
<input id="be-brightness" type="number" inputmode="numeric" min="0" max="255" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="preset-editor-field">
|
||||||
|
<span>Colors (c)</span>
|
||||||
|
<input id="be-colors" type="text" placeholder="r,g,b; r,g,b (0–255)" />
|
||||||
|
</label>
|
||||||
|
<div class="preset-editor-row">
|
||||||
|
<label class="preset-editor-field small">
|
||||||
|
<span>n1</span>
|
||||||
|
<input id="be-n1" type="number" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="preset-editor-field small">
|
||||||
|
<span>n2</span>
|
||||||
|
<input id="be-n2" type="number" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-row">
|
||||||
|
<label class="preset-editor-field small">
|
||||||
|
<span>n3</span>
|
||||||
|
<input id="be-n3" type="number" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="preset-editor-field small">
|
||||||
|
<span>n4</span>
|
||||||
|
<input id="be-n4" type="number" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-row">
|
||||||
|
<label class="preset-editor-field small">
|
||||||
|
<span>n5</span>
|
||||||
|
<input id="be-n5" type="number" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="preset-editor-field small">
|
||||||
|
<span>n6</span>
|
||||||
|
<input id="be-n6" type="number" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-row">
|
||||||
|
<label class="preset-editor-field small">
|
||||||
|
<span>n7</span>
|
||||||
|
<input id="be-n7" type="number" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
<label class="preset-editor-field small">
|
||||||
|
<span>n8</span>
|
||||||
|
<input id="be-n8" type="number" inputmode="numeric" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="preset-editor-actions">
|
||||||
|
<button type="button" class="preset-editor-btn primary" onclick="saveButtonFromEditor()">Save</button>
|
||||||
|
<button type="button" class="preset-editor-btn" onclick="closeButtonEditor()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="next-btn" type="button" onclick="nextPreset()">Next</button>
|
||||||
|
<script src="static/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user