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:
2026-03-03 19:28:08 +13:00
parent 615431d6c5
commit 646b988cdd
16 changed files with 3225 additions and 30 deletions

View File

@@ -0,0 +1,2 @@
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
send_file # noqa: F401

View 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 _

File diff suppressed because it is too large Load Diff

View 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

View 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)

View File

View 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

View 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)

View 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
View 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
View 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"}
]
}

View File

@@ -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)
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/<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:
led.value(0)
time.sleep_ms(1)
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
View 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
View 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
View 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 */

View 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 (0255)" />
</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>