Compare commits

...

2 Commits

Author SHA1 Message Date
c07e3e7a07 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
2026-04-02 00:34:17 +13:00
397d48a43a Add IMU telemetry pipeline and live sensor UI.
Wire LSM6DS3 readings into the runtime telemetry stream, expose them over web endpoints, and render live voltage/IMU data in the dashboard with websocket updates.

Made-with: Cursor
2026-04-01 23:04:41 +13:00
14 changed files with 1602 additions and 481 deletions

View File

@@ -12,4 +12,4 @@ watchfiles = "*"
[dev-packages] [dev-packages]
[requires] [requires]
python_version = "3.12" python_version = "3"

915
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

64
lib/lsm6ds3.py Normal file
View File

@@ -0,0 +1,64 @@
"""LSM6DS3 accelerometer / gyroscope over I2C (MicroPython)."""
import struct
WHO_AM_I_REG = 0x0F
WHO_AM_I_LSM6DS3 = 0x69
CTRL1_XL = 0x10
CTRL2_G = 0x11
OUT_TEMP_L = 0x20
# Default CTRL1_XL = 0x60: 104 Hz ODR, ±2 g, anti-aliasing filter BW ~100 Hz
# Default CTRL2_G = 0x40: 104 Hz ODR, 245 dps full scale
_DEFAULT_XL = b"\x60"
_DEFAULT_G = b"\x40"
# LSB scaling for default full-scale settings above
_G_PER_LSB = 0.000061
_DPS_PER_LSB = 0.00875
def is_lsm6ds3(who_am_i: int) -> bool:
return who_am_i == WHO_AM_I_LSM6DS3
class LSM6DS3:
def __init__(self, i2c, addr=0x6A):
self.i2c = i2c
self.addr = addr
def who_am_i(self) -> int:
return self.i2c.readfrom_mem(self.addr, WHO_AM_I_REG, 1)[0]
def configure(self, ctrl1_xl=_DEFAULT_XL, ctrl2_g=_DEFAULT_G) -> None:
self.i2c.writeto_mem(self.addr, CTRL1_XL, ctrl1_xl)
self.i2c.writeto_mem(self.addr, CTRL2_G, ctrl2_g)
def read_raw(self):
"""Returns (temp_raw, gx, gy, gz, ax, ay, az) as signed 16-bit values."""
data = self.i2c.readfrom_mem(self.addr, OUT_TEMP_L, 14)
return struct.unpack("<hhhhhhh", data)
def read(self):
"""
Scaled readings for default ±2 g / 245 dps configuration.
Returns:
temp_c: float, degrees C
accel_g: (ax, ay, az) in g
gyro_dps: (gx, gy, gz) in degrees per second
"""
temp, gx, gy, gz, ax, ay, az = self.read_raw()
temp_c = 25.0 + temp / 16.0
accel_g = (
ax * _G_PER_LSB,
ay * _G_PER_LSB,
az * _G_PER_LSB,
)
gyro_dps = (
gx * _DPS_PER_LSB,
gy * _DPS_PER_LSB,
gz * _DPS_PER_LSB,
)
return temp_c, accel_g, gyro_dps

51
src/buttons.json Normal file
View 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"}
]
}

View File

@@ -1,5 +1,7 @@
import asyncio import asyncio
import aioespnow import aioespnow
import math
import random
from settings import Settings from settings import Settings
from web import web from web import web
from patterns import Patterns from patterns import Patterns
@@ -10,7 +12,8 @@ import time
import wifi import wifi
import json import json
from p2p import p2p from p2p import p2p
from machine import ADC, Pin from machine import ADC, Pin, I2C
from lsm6ds3 import LSM6DS3, is_lsm6ds3
async def main(): async def main():
settings = Settings() settings = Settings()
@@ -31,12 +34,15 @@ async def main():
await asyncio.sleep_ms(0) await asyncio.sleep_ms(0)
async def system(): async def system():
adc = ADC(Pin(2), atten=ADC.ATTN_11DB) adc = ADC(Pin(0), atten=ADC.ATTN_11DB)
while True: while True:
gc.collect() gc.collect()
for i in range(60): for i in range(60):
wdt.feed() wdt.feed()
pin_uv = adc.read_uv()
voltage = pin_uv * 2 / 1_000_000 # 1:2 divider: V_source = 2 × V_pin
telemetry["voltage"] = voltage
print("%.3f V" % voltage)
await asyncio.sleep(1) await asyncio.sleep(1)
async def sw(): async def sw():
@@ -64,8 +70,108 @@ async def main():
patterns.select("off") patterns.select("off")
await asyncio.sleep_ms(200) await asyncio.sleep_ms(200)
telemetry = {
"voltage": 0.0,
"imu": {
"ok": False,
"temp": 0.0,
"ax": 0.0,
"ay": 0.0,
"az": 0.0,
"gx": 0.0,
"gy": 0.0,
"gz": 0.0,
"roll_deg": 0.0,
"pitch_deg": 0.0,
"yaw_deg": 0.0,
},
}
w = web(settings, patterns) imu_sensor = None
try:
i2c_imu = I2C(0, scl=Pin(23), sda=Pin(22), freq=400000)
for addr in (0x6A, 0x6B):
try:
cand = LSM6DS3(i2c_imu, addr)
if is_lsm6ds3(cand.who_am_i()):
cand.configure()
utime.sleep_ms(50)
imu_sensor = cand
print("LSM6DS3 at", hex(addr))
break
except OSError:
pass
except Exception as e:
print("IMU init failed:", e)
async def imu_loop():
im = telemetry["imu"]
prev_ms = utime.ticks_ms()
last_gyro_jolt_ms = 0
# Integrated rotation this sample: max(|ω|) * dt ≥ threshold (deg)
gyro_jolt_deg_per_interval = 22.0
gyro_jolt_cooldown_ms = 700
def wrap_angle_deg(y):
while y > 180.0:
y -= 360.0
while y <= -180.0:
y += 360.0
return y
def random_bright_rgb():
return (
random.randint(48, 255),
random.randint(48, 255),
random.randint(48, 255),
)
while True:
wdt.feed()
now = utime.ticks_ms()
dt_ms = utime.ticks_diff(now, prev_ms)
prev_ms = now
if dt_ms < 0:
dt_ms = 200
dt_s = max(dt_ms / 1000.0, 0.001)
try:
t, ag, gd = imu_sensor.read()
im["ok"] = True
im["temp"] = t
im["ax"], im["ay"], im["az"] = ag
im["gx"], im["gy"], im["gz"] = gd
ax, ay, az = ag
im["roll_deg"] = math.degrees(math.atan2(ay, az))
pitch = math.degrees(
math.atan2(-ax, math.sqrt(ay * ay + az * az))
)
im["pitch_deg"] = pitch
gx, gy, gz = gd
im["yaw_deg"] = wrap_angle_deg(im["yaw_deg"] + gz * dt_s)
omega_max = max(abs(gx), abs(gy), abs(gz))
spin_deg = omega_max * dt_s
if (
spin_deg >= gyro_jolt_deg_per_interval
and utime.ticks_diff(now, last_gyro_jolt_ms)
>= gyro_jolt_cooldown_ms
):
last_gyro_jolt_ms = now
patterns.set_color1(random_bright_rgb())
patterns.set_color2(random_bright_rgb())
patterns.sync()
print(
"gyro jolt %.1f deg (|ω|max=%.0f dps) -> new colors"
% (spin_deg, omega_max)
)
except OSError:
im["ok"] = False
except Exception as e:
im["ok"] = False
print("IMU read error:", e)
await asyncio.sleep_ms(200)
w = web(settings, patterns, telemetry)
print(settings) print(settings)
# start the server in a bacakground task # start the server in a bacakground task
print("Starting") print("Starting")
@@ -77,6 +183,8 @@ async def main():
asyncio.create_task(p2p(settings, patterns)) asyncio.create_task(p2p(settings, patterns))
asyncio.create_task(system()) asyncio.create_task(system())
asyncio.create_task(sw()) asyncio.create_task(sw())
if imu_sensor is not None:
asyncio.create_task(imu_loop())
# cleanup before ending the application # cleanup before ending the application
await server await server

187
src/static/hoop.css Normal file
View 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
View 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();

View File

@@ -9,6 +9,45 @@ h1 {
text-align: center; text-align: center;
} }
.sensor-section {
margin: 1.25rem 0;
padding: 1rem 1.25rem;
border: 1px solid #c5c5c5;
border-radius: 10px;
background: #f4f4f4;
box-sizing: border-box;
}
.sensor-section h2 {
margin: 0 0 0.35rem 0;
font-size: 1.15rem;
text-align: left;
}
.sensor-hint {
margin: 0 0 0.75rem 0;
font-size: 0.85rem;
color: #555;
}
.sensor-section .telemetry p {
margin: 0.4rem 0;
}
.sensor-section .imu-block {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #ddd;
}
.sensor-section .yaw-note {
display: block;
font-size: 0.8rem;
font-weight: normal;
color: #666;
margin-top: 0.2rem;
}
form { form {
margin-bottom: 20px; margin-bottom: 20px;
} }

View File

@@ -5,6 +5,52 @@ let color2Timeout;
let ws; // Variable to hold the WebSocket connection let ws; // Variable to hold the WebSocket connection
let connectionStatusElement; // Variable to hold the connection status element let connectionStatusElement; // Variable to hold the connection status element
function setTelemetryText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function applyTelemetryDisplay(data) {
if (typeof data.voltage === "number") {
setTelemetryText("voltage", data.voltage.toFixed(3));
}
const imu = data.imu;
const dash = "—";
const imuLive =
imu &&
(imu.ok === true ||
imu.ok === 1 ||
imu.ok === "1" ||
imu.ok === "true");
if (imuLive) {
const fmt = (v, d) =>
typeof v === "number" && !Number.isNaN(v) ? v.toFixed(d) : dash;
setTelemetryText("imu-temp", fmt(imu.temp, 2));
setTelemetryText("imu-ax", fmt(imu.ax, 3));
setTelemetryText("imu-ay", fmt(imu.ay, 3));
setTelemetryText("imu-az", fmt(imu.az, 3));
setTelemetryText("imu-gx", fmt(Math.abs(imu.gx), 2));
setTelemetryText("imu-gy", fmt(Math.abs(imu.gy), 2));
setTelemetryText("imu-gz", fmt(Math.abs(imu.gz), 2));
setTelemetryText("imu-roll", fmt(imu.roll_deg, 1));
setTelemetryText("imu-pitch", fmt(imu.pitch_deg, 1));
setTelemetryText("imu-yaw", fmt(imu.yaw_deg, 1));
} else {
setTelemetryText("imu-temp", dash);
[
"imu-ax",
"imu-ay",
"imu-az",
"imu-gx",
"imu-gy",
"imu-gz",
"imu-roll",
"imu-pitch",
"imu-yaw",
].forEach((id) => setTelemetryText(id, dash));
}
}
// Function to update the connection status indicator // Function to update the connection status indicator
function updateConnectionStatus(status) { function updateConnectionStatus(status) {
if (!connectionStatusElement) { if (!connectionStatusElement) {
@@ -20,8 +66,8 @@ function updateConnectionStatus(status) {
// Function to establish WebSocket connection // Function to establish WebSocket connection
function connectWebSocket() { function connectWebSocket() {
// Determine the WebSocket URL based on the current location const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `ws://${window.location.host}/ws`; const wsUrl = `${proto}//${window.location.host}/ws`;
ws = new WebSocket(wsUrl); ws = new WebSocket(wsUrl);
updateConnectionStatus("connecting"); // Indicate connecting state updateConnectionStatus("connecting"); // Indicate connecting state
@@ -33,7 +79,15 @@ function connectWebSocket() {
}; };
ws.onmessage = function (event) { ws.onmessage = function (event) {
console.log("WebSocket message received:", event.data); let msg;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
if (msg && msg._t === "telemetry") {
applyTelemetryDisplay(msg);
}
}; };
ws.onerror = function (event) { ws.onerror = function (event) {
@@ -86,18 +140,6 @@ async function post(path, data) {
} }
} }
async function get(path) {
try {
const response = await fetch(path);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Error during GET request:", error);
}
}
async function updateColor(event) { async function updateColor(event) {
event.preventDefault(); event.preventDefault();
clearTimeout(colorTimeout); clearTimeout(colorTimeout);
@@ -220,7 +262,6 @@ document.addEventListener("DOMContentLoaded", async function () {
document.getElementById("rgb").addEventListener("change", handleRadioChange); document.getElementById("rgb").addEventListener("change", handleRadioChange);
document.getElementById("rbg").addEventListener("change", handleRadioChange); document.getElementById("rbg").addEventListener("change", handleRadioChange);
document.querySelectorAll(".pattern_button").forEach((button) => { document.querySelectorAll(".pattern_button").forEach((button) => {
console.log(button.value);
button.addEventListener("click", async (event) => { button.addEventListener("click", async (event) => {
event.preventDefault(); event.preventDefault();
await updatePattern(button.value); await updatePattern(button.value);
@@ -231,14 +272,27 @@ document.addEventListener("DOMContentLoaded", async function () {
// Function to toggle the display of the settings menu // Function to toggle the display of the settings menu
function selectSettings() { function selectSettings() {
const settingsMenu = document.getElementById("settings_menu"); 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"; settingsMenu.style.display = "block";
controls.style.display = "none"; controls.style.display = "none";
if (hoopMenu) hoopMenu.style.display = "none";
} }
function selectControls() { function selectControls() {
const settingsMenu = document.getElementById("settings_menu"); 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"; settingsMenu.style.display = "none";
controls.style.display = "block"; 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
View 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>

View File

@@ -10,8 +10,46 @@
</head> </head>
<body> <body>
<h1>{{settings['name']}}</h1> <h1>{{settings['name']}}</h1>
<section class="sensor-section" id="sensor-section" aria-labelledby="sensor-heading">
<h2 id="sensor-heading">Sensors</h2>
<p class="sensor-hint">
Live readings (WebSocket). Roll/pitch use gravity (accel). Yaw is integrated from the
gyro (relative, drifts; not compass heading unless you add a magnetometer).
</p>
<div class="telemetry">
<p class="voltage"><strong>Voltage:</strong> <span id="voltage"></span> V</p>
<div id="imu-block" class="imu-block">
<p><strong>IMU temp:</strong> <span id="imu-temp"></span> °C</p>
<p>
<strong>Accel (g):</strong>
x <span id="imu-ax"></span>,
y <span id="imu-ay"></span>,
z <span id="imu-az"></span>
</p>
<p>
<strong>Gyro (abs °/s):</strong>
<span id="imu-gx"></span>,
<span id="imu-gy"></span>,
<span id="imu-gz"></span>
</p>
<p class="tilt-line">
<strong>Roll (°):</strong> <span id="imu-roll"></span>
</p>
<p class="tilt-line">
<strong>Pitch (°):</strong> <span id="imu-pitch"></span>
</p>
<p class="tilt-line">
<strong>Yaw (°):</strong> <span id="imu-yaw"></span>
<span class="yaw-note">gyro ∫z, 180…180</span>
</p>
</div>
</div>
</section>
<button onclick="selectControls()">Controls</button> <button onclick="selectControls()">Controls</button>
<button onclick="selectSettings()">Settings</button> <button onclick="selectSettings()">Settings</button>
<button onclick="selectHoop()">Hoop</button>
<!-- Main LED Controls --> <!-- Main LED Controls -->
<div id="controls"> <div id="controls">
@@ -119,6 +157,14 @@
<p>Mac address: {{mac}}</p> <p>Mac address: {{mac}}</p>
</div> </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> <div id="connection-status"></div>
</body> </body>
</html> </html>

View File

@@ -1,19 +1,91 @@
import asyncio
import utime
from microdot import Microdot, send_file, Response from microdot import Microdot, send_file, Response
from microdot.utemplate import Template from microdot.utemplate import Template
from microdot.websocket import with_websocket from microdot.websocket import WebSocketError, with_websocket
import machine import machine
import wifi import wifi
import json import json
def web(settings, patterns):
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"]
return {
"voltage": float(telemetry["voltage"]),
"imu": {
"ok": 1 if im.get("ok") else 0,
"temp": float(im["temp"]),
"ax": float(im["ax"]),
"ay": float(im["ay"]),
"az": float(im["az"]),
"gx": float(im["gx"]),
"gy": float(im["gy"]),
"gz": float(im["gz"]),
"roll_deg": float(im.get("roll_deg", 0.0)),
"pitch_deg": float(im.get("pitch_deg", 0.0)),
"yaw_deg": float(im.get("yaw_deg", 0.0)),
},
}
def web(settings, patterns, telemetry):
app = Microdot() app = Microdot()
Response.default_content_type = 'text/html' Response.default_content_type = 'text/html'
buttons = _load_buttons(patterns.patterns.keys())
ws_clients = set()
@app.route("/api/telemetry")
def telemetry_handler(request):
return Response(_telemetry_snapshot(telemetry))
@app.route('/') @app.route('/')
async def index_hnadler(request): async def index_hnadler(request):
mac = wifi.get_mac().hex() mac = wifi.get_mac().hex()
return Template('/index.html').render(settings=settings, patterns=patterns.patterns.keys(), mac=mac) 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>") @app.route("/static/<path:path>")
def static_handler(request, path): def static_handler(request, path):
if '..' in path: if '..' in path:
@@ -30,14 +102,74 @@ def web(settings, patterns):
@app.route("/ws") @app.route("/ws")
@with_websocket @with_websocket
async def ws(request, ws): async def ws(request, ws):
while True: # One coroutine only: a background send task interleaves badly with
data = await ws.receive() # receive() on ESP32. Use short wait_for slices so we keep receiving
if data: # client commands and still push telemetry on an interval.
push_every_ms = 1000
recv_slice_s = 0.2
last_push = utime.ticks_add(utime.ticks_ms(), -push_every_ms)
# Process the received data def telemetry_payload():
_, status_code = settings.set_settings(json.loads(data), patterns, True) try:
#await ws.send(status_code) snap = _telemetry_snapshot(telemetry)
else: snap["_t"] = "telemetry"
return json.dumps(snap)
except Exception:
return json.dumps(
{
"_t": "telemetry",
"voltage": 0.0,
"imu": {
"ok": 0,
"temp": 0.0,
"ax": 0.0,
"ay": 0.0,
"az": 0.0,
"gx": 0.0,
"gy": 0.0,
"gz": 0.0,
"roll_deg": 0.0,
"pitch_deg": 0.0,
"yaw_deg": 0.0,
},
}
)
ws_clients.add(ws)
while True:
try:
data = await asyncio.wait_for(ws.receive(), recv_slice_s)
except asyncio.TimeoutError:
data = None
except WebSocketError:
break break
if data:
try:
msg = json.loads(data)
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:
if ws.closed:
break
try:
await ws.send(telemetry_payload())
except (OSError, WebSocketError):
break
last_push = now
ws_clients.discard(ws)
return app return app

9
test/leds.py Normal file
View File

@@ -0,0 +1,9 @@
from machine import Pin
from neopixel import NeoPixel
led = NeoPixel(Pin(18, Pin.OUT), 10)
led.fill((255, 0, 0))
led.write()

74
test/lsm6ds3_test.py Normal file
View File

@@ -0,0 +1,74 @@
from machine import Pin, I2C
import time
from lsm6ds3 import LSM6DS3, is_lsm6ds3
# XIAO ESP32C3 default I2C pins: SDA=GPIO6 (D4), SCL=GPIO7 (D5)
i2c = I2C(0, scl=Pin(23), sda=Pin(22), freq=400000)
def scan_i2c():
devices = i2c.scan()
if not devices:
print("No I2C devices found")
else:
print("I2C devices found:", [hex(d) for d in devices])
def read_who_am_i(addr):
try:
sensor = LSM6DS3(i2c, addr)
who = sensor.who_am_i()
print("WHO_AM_I at", hex(addr), "=", hex(who))
if is_lsm6ds3(who):
print("Looks like an LSM6DS3")
else:
print("Unexpected WHO_AM_I value")
except OSError as e:
print("Failed to read WHO_AM_I from", hex(addr), "error:", e)
def basic_test():
"""Run an I2C scan and WHO_AM_I check on common LSM6DS3 addresses."""
scan_i2c()
for addr in (0x6A, 0x6B):
read_who_am_i(addr)
def configure_and_read(addr=0x6A):
"""Configure accel/gyro and continuously print readings."""
sensor = LSM6DS3(i2c, addr)
sensor.configure()
time.sleep_ms(100)
while True:
try:
temp_c, accel_g, gyro_dps = sensor.read()
ax_g, ay_g, az_g = accel_g
gx_dps, gy_dps, gz_dps = gyro_dps
print("Temp: {:.2f} C".format(temp_c))
print(
"Accel g: ax={:.3f}, ay={:.3f}, az={:.3f}".format(
ax_g, ay_g, az_g
)
)
print(
"Gyro dps: gx={:.2f}, gy={:.2f}, gz={:.2f}".format(
gx_dps, gy_dps, gz_dps
)
)
print("----")
except OSError as e:
print("I2C read error:", e)
time.sleep(0.1)
if __name__ == "__main__":
# First run a basic scan + WHO_AM_I test.
basic_test()
# Uncomment this to go straight into continuous sensor reading once
# you know the correct I2C address for your board (0x6A or 0x6B).
configure_and_read(addr=0x6B)