Compare commits
2 Commits
d0d73b422d
...
c07e3e7a07
| Author | SHA1 | Date | |
|---|---|---|---|
| c07e3e7a07 | |||
| 397d48a43a |
2
Pipfile
2
Pipfile
@@ -12,4 +12,4 @@ watchfiles = "*"
|
|||||||
[dev-packages]
|
[dev-packages]
|
||||||
|
|
||||||
[requires]
|
[requires]
|
||||||
python_version = "3.12"
|
python_version = "3"
|
||||||
|
|||||||
915
Pipfile.lock
generated
915
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
64
lib/lsm6ds3.py
Normal file
64
lib/lsm6ds3.py
Normal 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
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"}
|
||||||
|
]
|
||||||
|
}
|
||||||
118
src/main.py
118
src/main.py
@@ -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():
|
||||||
@@ -63,9 +69,109 @@ async def main():
|
|||||||
if sw2.value() == 0:
|
if sw2.value() == 0:
|
||||||
patterns.select("off")
|
patterns.select("off")
|
||||||
await asyncio.sleep_ms(200)
|
await asyncio.sleep_ms(200)
|
||||||
|
|
||||||
|
|
||||||
w = web(settings, patterns)
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
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();
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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>
|
||||||
@@ -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>
|
||||||
|
|||||||
150
src/web.py
150
src/web.py
@@ -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
9
test/leds.py
Normal 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
74
test/lsm6ds3_test.py
Normal 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)
|
||||||
Reference in New Issue
Block a user