Add hoop UI, button APIs, and in-app hoop tab.
Integrate the led-hoop frontend as a dedicated hoop view with persisted button endpoints and websocket sync, and expose it from the main page via a new Hoop tab alongside controls/settings. Made-with: Cursor
This commit is contained in:
51
src/buttons.json
Normal file
51
src/buttons.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"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": "pose2", "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": "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"}
|
||||
]
|
||||
}
|
||||
187
src/static/hoop.css
Normal file
187
src/static/hoop.css
Normal file
@@ -0,0 +1,187 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #555;
|
||||
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; }
|
||||
|
||||
.next-btn {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
padding: 2rem 1rem;
|
||||
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;
|
||||
background: #222;
|
||||
border: 1px solid #555;
|
||||
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 input {
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #555;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.preset-editor-actions {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
246
src/static/hoop.js
Normal file
246
src/static/hoop.js
Normal file
@@ -0,0 +1,246 @@
|
||||
var ws = null;
|
||||
var currentEditIndex = -1;
|
||||
|
||||
function getButtonsFromDom() {
|
||||
var btns = document.querySelectorAll('#buttonsContainer .btn');
|
||||
return Array.prototype.map.call(btns, function (el) {
|
||||
return {
|
||||
id: el.getAttribute('data-id') || el.textContent.trim(),
|
||||
preset: el.getAttribute('data-preset') || ''
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
el.draggable = true;
|
||||
el.textContent = btn.id;
|
||||
container.appendChild(el);
|
||||
});
|
||||
attachButtonListeners();
|
||||
}
|
||||
|
||||
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);
|
||||
}).catch(function() {
|
||||
if (callback) callback({ ok: false });
|
||||
});
|
||||
}
|
||||
|
||||
function saveCurrentButtons() {
|
||||
saveButtons(getButtonsFromDom(), function() {});
|
||||
}
|
||||
|
||||
var contextMenuEl = null;
|
||||
var longPressTimer = null;
|
||||
var longPressTriggered = false;
|
||||
|
||||
function attachButtonListeners() {
|
||||
var btns = document.querySelectorAll('#buttonsContainer .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);
|
||||
}
|
||||
}
|
||||
|
||||
function openNewButtonEditor() {
|
||||
currentEditIndex = -1;
|
||||
document.getElementById('buttonEditorTitle').textContent = 'New button';
|
||||
document.getElementById('be-label').value = '';
|
||||
document.getElementById('be-preset').value = '';
|
||||
openButtonEditor();
|
||||
}
|
||||
|
||||
function openExistingButtonEditor(index) {
|
||||
currentEditIndex = index;
|
||||
var buttons = getButtonsFromDom();
|
||||
var btn = buttons[index];
|
||||
document.getElementById('buttonEditorTitle').textContent = 'Edit button';
|
||||
document.getElementById('be-label').value = btn.id || '';
|
||||
document.getElementById('be-preset').value = btn.preset || '';
|
||||
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 saveButtonFromEditor() {
|
||||
var label = (document.getElementById('be-label').value || '').trim();
|
||||
var preset = (document.getElementById('be-preset').value || '').trim();
|
||||
var btn = { id: label || preset, preset: preset || label };
|
||||
var buttons = getButtonsFromDom();
|
||||
if (currentEditIndex >= 0 && currentEditIndex < buttons.length) buttons[currentEditIndex] = btn;
|
||||
else buttons.push(btn);
|
||||
renderButtons(buttons);
|
||||
saveButtons(buttons, function() {});
|
||||
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);
|
||||
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() {});
|
||||
};
|
||||
}
|
||||
|
||||
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._t === 'telemetry') return;
|
||||
if (msg.select != null || msg.pattern != null) {
|
||||
var preset = msg.pattern || msg.select;
|
||||
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');
|
||||
}
|
||||
}
|
||||
} 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');
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
document.getElementById('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 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);
|
||||
}
|
||||
|
||||
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 () {
|
||||
renderButtons(getButtonsFromDom());
|
||||
});
|
||||
|
||||
connect();
|
||||
@@ -272,14 +272,27 @@ document.addEventListener("DOMContentLoaded", async function () {
|
||||
// Function to toggle the display of the settings menu
|
||||
function selectSettings() {
|
||||
const settingsMenu = document.getElementById("settings_menu");
|
||||
controls = document.getElementById("controls");
|
||||
const controls = document.getElementById("controls");
|
||||
const hoopMenu = document.getElementById("hoop_menu");
|
||||
settingsMenu.style.display = "block";
|
||||
controls.style.display = "none";
|
||||
if (hoopMenu) hoopMenu.style.display = "none";
|
||||
}
|
||||
|
||||
function selectControls() {
|
||||
const settingsMenu = document.getElementById("settings_menu");
|
||||
controls = document.getElementById("controls");
|
||||
const controls = document.getElementById("controls");
|
||||
const hoopMenu = document.getElementById("hoop_menu");
|
||||
settingsMenu.style.display = "none";
|
||||
controls.style.display = "block";
|
||||
if (hoopMenu) hoopMenu.style.display = "none";
|
||||
}
|
||||
|
||||
function selectHoop() {
|
||||
const settingsMenu = document.getElementById("settings_menu");
|
||||
const controls = document.getElementById("controls");
|
||||
const hoopMenu = document.getElementById("hoop_menu");
|
||||
settingsMenu.style.display = "none";
|
||||
controls.style.display = "none";
|
||||
if (hoopMenu) hoopMenu.style.display = "block";
|
||||
}
|
||||
|
||||
92
src/templates/hoop.html
Normal file
92
src/templates/hoop.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% args buttons %}
|
||||
<!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/hoop.css" />
|
||||
</head>
|
||||
<body>
|
||||
<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="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>
|
||||
<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/hoop.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
{% args buttons %}
|
||||
<!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/hoop.css" />
|
||||
</head>
|
||||
<body>
|
||||
<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="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>
|
||||
<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/hoop.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -49,6 +49,7 @@
|
||||
|
||||
<button onclick="selectControls()">Controls</button>
|
||||
<button onclick="selectSettings()">Settings</button>
|
||||
<button onclick="selectHoop()">Hoop</button>
|
||||
|
||||
<!-- Main LED Controls -->
|
||||
<div id="controls">
|
||||
@@ -156,6 +157,14 @@
|
||||
|
||||
<p>Mac address: {{mac}}</p>
|
||||
</div>
|
||||
<div id="hoop_menu" style="display: none">
|
||||
<iframe
|
||||
id="hoop_iframe"
|
||||
src="/hoop"
|
||||
title="Hoop Controls"
|
||||
style="width: 100%; min-height: 75vh; border: 0"
|
||||
></iframe>
|
||||
</div>
|
||||
<div id="connection-status"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
54
src/web.py
54
src/web.py
@@ -9,6 +9,27 @@ import wifi
|
||||
import json
|
||||
|
||||
|
||||
def _load_buttons(default_buttons):
|
||||
try:
|
||||
with open("buttons.json") as f:
|
||||
raw = json.load(f)
|
||||
buttons = raw.get("buttons", [])
|
||||
if isinstance(buttons, list):
|
||||
return buttons
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
pass
|
||||
return [{"id": p, "preset": p} for p in default_buttons]
|
||||
|
||||
|
||||
def _save_buttons(buttons):
|
||||
try:
|
||||
with open("buttons.json", "w") as f:
|
||||
json.dump({"buttons": buttons}, f)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _telemetry_snapshot(telemetry):
|
||||
"""Flat scalars for JSON (MicroPython-safe); imu.ok as 0/1 for clients."""
|
||||
im = telemetry["imu"]
|
||||
@@ -33,6 +54,8 @@ def _telemetry_snapshot(telemetry):
|
||||
def web(settings, patterns, telemetry):
|
||||
app = Microdot()
|
||||
Response.default_content_type = 'text/html'
|
||||
buttons = _load_buttons(patterns.patterns.keys())
|
||||
ws_clients = set()
|
||||
|
||||
@app.route("/api/telemetry")
|
||||
def telemetry_handler(request):
|
||||
@@ -43,6 +66,26 @@ def web(settings, patterns, telemetry):
|
||||
mac = wifi.get_mac().hex()
|
||||
return Template('/index.html').render(settings=settings, patterns=patterns.patterns.keys(), mac=mac)
|
||||
|
||||
@app.route('/hoop')
|
||||
async def hoop_handler(request):
|
||||
return Template('/hoop.html').render(buttons=buttons)
|
||||
|
||||
@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):
|
||||
nonlocal buttons
|
||||
data = request.json or {}
|
||||
new_buttons = data.get("buttons", [])
|
||||
if not isinstance(new_buttons, list):
|
||||
return {"ok": False, "error": "buttons must be a list"}, 400
|
||||
if not _save_buttons(new_buttons):
|
||||
return {"ok": False, "error": "save failed"}, 500
|
||||
buttons = new_buttons
|
||||
return {"ok": True}
|
||||
|
||||
@app.route("/static/<path:path>")
|
||||
def static_handler(request, path):
|
||||
if '..' in path:
|
||||
@@ -92,6 +135,7 @@ def web(settings, patterns, telemetry):
|
||||
}
|
||||
)
|
||||
|
||||
ws_clients.add(ws)
|
||||
while True:
|
||||
try:
|
||||
data = await asyncio.wait_for(ws.receive(), recv_slice_s)
|
||||
@@ -106,7 +150,16 @@ def web(settings, patterns, telemetry):
|
||||
except (ValueError, TypeError):
|
||||
msg = None
|
||||
if isinstance(msg, dict) and msg.get("_t") != "telemetry":
|
||||
if "select" in msg and "pattern" not in msg:
|
||||
msg = {"pattern": msg["select"]}
|
||||
settings.set_settings(msg, patterns, True)
|
||||
for other in ws_clients:
|
||||
if other is ws or other.closed:
|
||||
continue
|
||||
try:
|
||||
await other.send(data)
|
||||
except (OSError, WebSocketError):
|
||||
pass
|
||||
|
||||
now = utime.ticks_ms()
|
||||
if utime.ticks_diff(now, last_push) >= push_every_ms:
|
||||
@@ -117,5 +170,6 @@ def web(settings, patterns, telemetry):
|
||||
except (OSError, WebSocketError):
|
||||
break
|
||||
last_push = now
|
||||
ws_clients.discard(ws)
|
||||
|
||||
return app
|
||||
|
||||
Reference in New Issue
Block a user