Compare commits

...

5 Commits

Author SHA1 Message Date
nikrozman 395fd9b839 Implement BLE OTA 2026-03-19 22:11:45 +01:00
nikrozman 87fc2a3574 Update 3D model 2026-03-19 21:47:42 +01:00
nikrozman 5f928d7c92 Don't calibrate while device is moving 2026-03-19 20:19:26 +01:00
nikrozman 532ba4f719 Remove button mapping 2026-03-19 20:18:17 +01:00
nikrozman 19b96c9b21 Better smoothing 2026-03-08 11:56:49 +01:00
12 changed files with 625 additions and 456 deletions
+2 -1
View File
@@ -5,4 +5,5 @@
*.claude
*.vscode
web/version.js
samples/
samples/
firmware_dfu.zip
+379 -239
View File
@@ -1,290 +1,430 @@
# IMU Pointer Enclosure
"""
IMU Pointer Enclosure — v6
============================
Fixes from v5 (diagnosed from rendered images):
1. BOTTOM HOLE:
v5 built the bottom shell from a full-height box (0→H) then trimmed
at SPLIT_Z. The taper wedge interacted badly with the split cut near
X=0 leaving a hole in the floor. Fix: build bottom outer solid only
to SPLIT_Z height — no trimming step needed, no interaction.
2. TOP SHELL WALLS PROTRUDING BELOW SPLIT:
v5 top_outer started at SPLIT_Z but the cavity inside started at
SPLIT_Z + WALL, leaving WALL=3.5mm of solid wall below the groove —
visually protruding past the bottom shell. Fix: the top shell outer
solid starts at SPLIT_Z. The groove is cut starting exactly at SPLIT_Z
so there is zero protrusion below the split line.
3. BUTTON CAP NOT FLUSH / SITTING PROUD:
Aperture cylinder had arithmetic-derived Z that could miss the top wall
faces after filleting. Fix: aperture runs from Z=SPLIT_Z (well inside
the cavity) all the way to Z=H+2 — brute-force punch, impossible to miss.
4. CAP RIM SITS UNDER OUTER FACE (not above it):
Cap is placed so shaft top = H (flush). Rim hangs BELOW the top face
inside the cavity — correct retention geometry. The cap shaft top is
exactly flush with H. No part of the cap protrudes above H.
Split joint design:
- Bottom shell has a TONGUE that projects UP from SPLIT_Z.
The tongue is a thin rectangular frame (inner perimeter of the walls).
- Top shell has a matching GROOVE cut into the inside of its lower edge,
starting exactly at SPLIT_Z (the bottom face of the top shell).
- Two flex notches cut through the tongue on the long sides allow snap fit.
"""
import FreeCAD as App
import FreeCADGui as Gui
import Part
import math
from FreeCAD import Base
doc = App.newDocument("pointer")
doc = App.newDocument("pointer_v7")
# Global dimensions
L = 115.0
W = 36.0
H = 20.0
WALL = 3.5
CR = 3.0
TOL = 0.25
# ─────────────────────────────────────────────────────────────────────────────
# DIMENSIONS
# ─────────────────────────────────────────────────────────────────────────────
L = 115.0 # length (X): front=0, back=L
W = 36.0 # width (Y)
H = 22.0 # height (Z): bottom=0, top=H
WALL = 4.5 # wall thickness (+1 mm vs v6 — closes taper floor gap)
CR = 5.0 # corner fillet radius (vertical edges)
TOL = 0.25 # fit tolerance
# Rail and lid
RAIL_H = 4.5
RAIL_D = 2.0
LIP_H = 2.0
LIP_OVER = 1.5
LIP_EMBED = 0.2
# Taper: front of bottom shell is TAPER_RISE mm shorter than back.
# Applied only to the BOTTOM shell (it's where the ergonomic taper lives).
TAPER_RISE = 0.0 # no taper — removed per user request
TAPER_LEN = 100.0 # unused but kept to avoid NameError
LID_H = RAIL_H - LIP_H - TOL - 0.55
# Split plane
USBC_W = 11.0
USBC_H = 7.0
USBC_Z = 5.0
SPLIT_Z = USBC_Z + USBC_H + 2.5 # = 14.5 mm
# Board dimensions
PCB_T = 1.0
BRD_L = 21.0
BRD_W = 17.5
BRD_X = WALL
BRD_Y = (W - BRD_W) / 2
# Tongue/groove clip joint
TONGUE_H = 2.5 # how far tongue projects above SPLIT_Z
TONGUE_T = 1.2 # tongue wall thickness
GROOVE_H = TONGUE_H + TOL
GROOVE_T = TONGUE_T + TOL
# Flex notch through tongue (for snap release)
NOTCH_W = 8.0
NOTCH_H = TONGUE_H + 0.5
# ─────────────────────────────────────────────────────────────────────────────
# IMU BOARD
# ─────────────────────────────────────────────────────────────────────────────
PCB_T = 1.0
BRD_L = 21.0
BRD_W = 17.5
BRD_X = WALL
BRD_Y = (W - BRD_W) / 2.0
PLATFORM_H = 0.5
BRD_Z = WALL + PLATFORM_H
BRD_Z = WALL + PLATFORM_H
ARM_LEN = 5.0
ARM_THICK = 1.6
ARM_H = BRD_Z + PCB_T + 0.8
CLIP_TOL = 0.35
# Clip arms
ARM_LEN = 5.0
ARM_THICK = 1.6
ARM_H = BRD_Z + PCB_T + 0.8
CLIP_TOL = 0.35
# ─────────────────────────────────────────────────────────────────────────────
# BATTERY BAY
# ─────────────────────────────────────────────────────────────────────────────
BAT_L = 50.0
BAT_W = 12.0
BAT_H = 12.0
BAT_X = BRD_X + BRD_L + 8.0
BAT_Y = (W - BAT_W) / 2.0
BAT_CLIP_Y = 8.0
# USB-C cutout
USBC_W = 11.0
USBC_H = 7.0
USBC_Z = 4.5
# ─────────────────────────────────────────────────────────────────────────────
# BUTTON
# ─────────────────────────────────────────────────────────────────────────────
BTN_X = 28.0
BTN_CY = W / 2.0
BTN_HOLE_R = 8.0
CAP_SHAFT_R = BTN_HOLE_R - 0.4 # 0.4 mm radial clearance in hole
CAP_SHAFT_H = WALL # shaft fills top wall → top face flush
CAP_RIM_R = BTN_HOLE_R + 2.0 # 2 mm wider than hole → retention
CAP_RIM_H = 1.5
NUBBIN_R = 1.8
NUBBIN_H = 2.0
# Battery section
BAT_L = 50.0
BAT_W = 12.0
BAT_H = 12.0
BAT_X = BRD_X + BRD_L + 8.0
BAT_Y = (W - BAT_W) / 2
BAT_CLIP_Y = 8.0
# Switch geometry (adjust to match your Omron)
SWITCH_BODY_H = 5.0
STEM_H = 2.5
GAP = 0.5
# Circular notch
NOTCH_R = (USBC_W + TOL) / 2
NOTCH_DEPTH = WALL + 2.0
# PCB position derived top-down
PCB_TOP_Z = H - WALL - CAP_RIM_H - NUBBIN_H - GAP - SWITCH_BODY_H - STEM_H
PCB_BOT_Z = PCB_TOP_Z - PCB_T
# Clamp: must be inside the top shell cavity
floor_top_shell = SPLIT_Z + WALL
if PCB_BOT_Z < floor_top_shell + 0.5:
PCB_BOT_Z = floor_top_shell + 0.5
PCB_TOP_Z = PCB_BOT_Z + PCB_T
POST_H = PCB_BOT_Z - floor_top_shell
POST_OD = 4.0; POST_R = POST_OD / 2.0
POST_ID = 1.9; POST_IR = POST_ID / 2.0
POST_SEP = 3.0
# Circular notch helper
def circular_notch(face_x, cy, cz, r, depth):
circle = Part.makeCircle(r, Base.Vector(face_x, cy, cz), Base.Vector(1, 0, 0))
face = Part.Face(Part.Wire(circle))
return face.extrude(Base.Vector(-depth, 0, 0))
BPCB_L = 16.0
BPCB_W = 16.0
SHELF_WALL = 2.0
# ─────────────────────────────────────────────────────────────────────────────
# HELPERS
# ─────────────────────────────────────────────────────────────────────────────
# Rounded box helper
def rbox(lx, ly, lz, ox=0, oy=0, oz=0, r=CR):
b = Part.makeBox(lx, ly, lz, Base.Vector(ox, oy, oz))
try:
vert = [e for e in b.Edges
if abs(e.Vertexes[0].Z - e.Vertexes[1].Z) > lz * 0.9]
if vert:
b = b.makeFillet(r, vert)
except Exception:
pass
return b
# Simple box helper
def box(lx, ly, lz, ox=0, oy=0, oz=0):
def box(lx, ly, lz, ox=0.0, oy=0.0, oz=0.0):
return Part.makeBox(lx, ly, lz, Base.Vector(ox, oy, oz))
def cyl(r, h, cx=0.0, cy=0.0, cz=0.0):
return Part.makeCylinder(r, h, Base.Vector(cx, cy, cz))
# Rounded slot helper
def rounded_slot(depth, w, h, ox, oy, oz, r=None):
if r is None:
r = h / 2.0
def rounded_slot(depth, sw, sh, ox, oy, oz):
"""Stadium slot extruded in +X."""
r = min(sh / 2.0, sw / 2.0)
cy = oy + sw / 2.0
cz = oz + sh / 2.0
hw = max(sw / 2.0 - r, 0.0)
if hw < 1e-6:
circ = Part.makeCircle(r, Base.Vector(ox, cy, cz), Base.Vector(1, 0, 0))
return Part.Face(Part.Wire(circ)).extrude(Base.Vector(depth, 0, 0))
l_s = Base.Vector(ox, cy - hw, cz - r)
l_m = Base.Vector(ox, cy - hw - r, cz)
l_e = Base.Vector(ox, cy - hw, cz + r)
r_s = Base.Vector(ox, cy + hw, cz + r)
r_m = Base.Vector(ox, cy + hw + r, cz)
r_e = Base.Vector(ox, cy + hw, cz - r)
wire = Part.Wire([Part.Arc(l_s, l_m, l_e).toShape(),
Part.makeLine(l_e, r_s),
Part.Arc(r_s, r_m, r_e).toShape(),
Part.makeLine(r_e, l_s)])
return Part.Face(wire).extrude(Base.Vector(depth, 0, 0))
r = min(r, h / 2.0, w / 2.0)
import math
cy = oy + w / 2.0
cz = oz + h / 2.0
hw = w / 2.0 - r
def pt(cx, cy_v, cz_v, angle_deg, radius):
a = math.radians(angle_deg)
return Base.Vector(
cx,
cy_v + radius * math.cos(a),
cz_v + radius * math.sin(a)
)
l_start = pt(ox, cy - hw, cz, 270, r)
l_mid = pt(ox, cy - hw, cz, 180, r)
l_end = pt(ox, cy - hw, cz, 90, r)
arc_left = Part.Arc(l_start, l_mid, l_end).toShape()
line_top = Part.makeLine(
l_end,
pt(ox, cy + hw, cz, 90, r)
)
r_start = pt(ox, cy + hw, cz, 90, r)
r_mid = pt(ox, cy + hw, cz, 0, r)
r_end = pt(ox, cy + hw, cz, 270, r)
arc_right = Part.Arc(r_start, r_mid, r_end).toShape()
line_bot = Part.makeLine(r_end, l_start)
wire = Part.Wire([arc_left, line_top, arc_right, line_bot])
face = Part.Face(wire)
return face.extrude(Base.Vector(depth, 0, 0))
# Board clip helper
def make_clip(corner_x, corner_y, inward_x, inward_y):
def fillet_vert(solid, r, min_len=4.0):
"""Fillet edges that are primarily vertical (parallel to Z)."""
try:
edges = [e for e in solid.Edges
if len(e.Vertexes) == 2
and abs(e.Vertexes[0].X - e.Vertexes[1].X) < 1e-3
and abs(e.Vertexes[0].Y - e.Vertexes[1].Y) < 1e-3
and e.Length >= min_len]
if edges:
return solid.makeFillet(r, edges)
except Exception as e:
print(f" fillet_vert skipped: {e}")
return solid
def make_clip(cx, cy, ix, iy):
plat_w = ARM_THICK + CLIP_TOL
plat_x = cx if ix > 0 else cx - plat_w
plat_y = cy if iy > 0 else cy - plat_w
plat = box(plat_w, plat_w, PLATFORM_H + PCB_T, plat_x, plat_y, WALL)
ax_ox = cx if ix > 0 else cx - ARM_LEN
ax_oy = cy - ARM_THICK - CLIP_TOL if iy > 0 else cy + CLIP_TOL
arm_x = box(ARM_LEN, ARM_THICK, ARM_H, ax_ox, ax_oy, WALL)
ay_oy = cy if iy > 0 else cy - ARM_LEN
ay_ox = cx - ARM_THICK - CLIP_TOL if ix > 0 else cx + CLIP_TOL
arm_y = box(ARM_THICK, ARM_LEN, ARM_H, ay_ox, ay_oy, WALL)
cb_w = ARM_THICK + CLIP_TOL
cb_ox = cx - cb_w if ix > 0 else cx
cb_oy = cy - cb_w if iy > 0 else cy
cb = box(cb_w, cb_w, ARM_H, cb_ox, cb_oy, WALL)
return plat.fuse(arm_x.fuse(arm_y).fuse(cb))
plat_x = corner_x if inward_x > 0 else corner_x - plat_w
plat_y = corner_y if inward_y > 0 else corner_y - plat_w
# ═════════════════════════════════════════════════════════════════════════════
# BOTTOM SHELL (Z = 0 → SPLIT_Z, open on top)
# ═════════════════════════════════════════════════════════════════════════════
platform = box(
plat_w, plat_w,
PLATFORM_H + PCB_T,
plat_x, plat_y, WALL
)
# 1. Outer solid — built ONLY to SPLIT_Z height
bot_outer = box(L, W, SPLIT_Z)
bot_outer = fillet_vert(bot_outer, CR, min_len=SPLIT_Z * 0.4)
ax_ox = corner_x if inward_x > 0 else corner_x - ARM_LEN
ax_oy = corner_y - ARM_THICK - CLIP_TOL if inward_y > 0 else corner_y + CLIP_TOL
arm_x = box(ARM_LEN, ARM_THICK, ARM_H, ax_ox, ax_oy, WALL)
# Fillet the long horizontal edges the user holds.
# These are the 4 edges running in X at Z≈0 and Z≈SPLIT_Z, on both long sides.
# Same fillet applied to equivalent edges on the top shell later.
EDGE_FILLET = 2.5 # mm — soft and comfortable, visible but not decorative
try:
h_edges = []
for e in bot_outer.Edges:
if len(e.Vertexes) != 2:
continue
v0, v1 = e.Vertexes[0], e.Vertexes[1]
dx = abs(v0.X - v1.X)
dz = abs(v0.Z - v1.Z)
dy = abs(v0.Y - v1.Y)
# Long edge in X, horizontal, on a long side face —
# but ONLY at Z≈0 (bottom face). Exclude Z≈SPLIT_Z (the join edge).
z_mid = (v0.Z + v1.Z) / 2.0
if dx > L * 0.5 and dz < 0.5 and dy < 0.5 and z_mid < 1.0:
h_edges.append(e)
if h_edges:
bot_outer = bot_outer.makeFillet(EDGE_FILLET, h_edges)
print(f"Bottom shell: filleted {len(h_edges)} horizontal edge(s) R={EDGE_FILLET}")
else:
print("Bottom shell: no horizontal edges found — skipped")
except Exception as exc:
print(f"Bottom shell horizontal fillet skipped: {exc}")
ay_oy = corner_y if inward_y > 0 else corner_y - ARM_LEN
ay_ox = corner_x - ARM_THICK - CLIP_TOL if inward_x > 0 else corner_x + CLIP_TOL
arm_y = box(ARM_THICK, ARM_LEN, ARM_H, ay_ox, ay_oy, WALL)
# No taper wedge — removed per user request
corner_block_w = ARM_THICK + CLIP_TOL
cb_ox = corner_x - corner_block_w if inward_x > 0 else corner_x
cb_oy = corner_y - corner_block_w if inward_y > 0 else corner_y
# 3. Inner cavity — floor at WALL, ceiling at SPLIT_Z (open top, no ceiling)
bot_cav_lx = L - WALL * 2
bot_cav_ly = W - WALL * 2
bot_cav_lz = SPLIT_Z - WALL # floor(WALL) → SPLIT_Z
bot_inner = box(bot_cav_lx, bot_cav_ly, bot_cav_lz, WALL, WALL, WALL)
bot_shell = bot_outer.cut(bot_inner)
corner_block = box(
corner_block_w, corner_block_w, ARM_H,
cb_ox, cb_oy, WALL
)
# 4. Tongue (projects UP from SPLIT_Z, inner perimeter frame)
# Outer edge of tongue = inner face of outer wall = WALL from outside
# Inner edge of tongue = WALL + TONGUE_T from outside
t_slab = box(bot_cav_lx, bot_cav_ly, TONGUE_H,
WALL, WALL, SPLIT_Z)
t_cut = box(bot_cav_lx - TONGUE_T*2, bot_cav_ly - TONGUE_T*2, TONGUE_H + 1,
WALL + TONGUE_T, WALL + TONGUE_T, SPLIT_Z - 0.5)
tongue = t_slab.cut(t_cut)
return platform.fuse(arm_x.fuse(arm_y).fuse(corner_block))
# Flex notches on the two long sides (parallel to X)
nx0 = L / 2.0 - NOTCH_W / 2.0
for ny_start in [WALL, W - WALL - TONGUE_T]:
tongue = tongue.cut(
box(NOTCH_W, TONGUE_T + 0.5, NOTCH_H,
nx0, ny_start - 0.1, SPLIT_Z - 0.1))
bot_shell = bot_shell.fuse(tongue)
# Base outer body
base = rbox(L, W, H)
# Inner cavity
base = base.cut(
box(L - WALL * 2, W - WALL * 2, H - WALL,
WALL, WALL, WALL)
)
rail_z = H - RAIL_H
groove_h = RAIL_H - LIP_H
# Rail grooves
base = base.cut(
box(L - WALL * 2, RAIL_D, groove_h,
WALL, WALL - RAIL_D, rail_z)
)
base = base.cut(
box(L - WALL * 2, RAIL_D, groove_h,
WALL, W - WALL, rail_z)
)
# Lid lips
lip_z = H - LIP_H
base = base.fuse(
box(L - WALL * 2, LIP_OVER, LIP_H,
WALL, WALL, lip_z)
)
base = base.fuse(
box(L - WALL * 2, LIP_OVER, LIP_H,
WALL, W - WALL - LIP_OVER, lip_z)
)
# Back slot
slot_y0 = WALL - RAIL_D
slot_yw = W - WALL * 2 + RAIL_D * 2
base = base.cut(
box(WALL + 1.0, slot_yw, RAIL_H,
L - WALL, slot_y0, rail_z)
)
# Entry bump
BUMP_H = 0.5
pad_raw = box(WALL, slot_yw, BUMP_H,
L - WALL, slot_y0, rail_z)
pad_trimmed = pad_raw.common(rbox(L, W, H))
base = base.fuse(pad_trimmed)
# Board clips
clip_corners = [
(BRD_X, BRD_Y, +1, +1),
(BRD_X + BRD_L, BRD_Y, -1, +1),
(BRD_X, BRD_Y + BRD_W, +1, -1),
# 5. IMU clips
for cx, cy, ix, iy in [
(BRD_X, BRD_Y, +1, +1),
(BRD_X + BRD_L, BRD_Y, -1, +1),
(BRD_X, BRD_Y + BRD_W, +1, -1),
(BRD_X + BRD_L, BRD_Y + BRD_W, -1, -1),
]
]:
bot_shell = bot_shell.fuse(make_clip(cx, cy, ix, iy))
for cx, cy, ix, iy in clip_corners:
base = base.fuse(make_clip(cx, cy, ix, iy))
# 6. USB-C slot — starts at X = -WALL*3 so it punches through the rounded
# front face cleanly regardless of fillet radius
bot_shell = bot_shell.cut(
rounded_slot(WALL * 6, USBC_W, USBC_H,
-WALL * 3,
W / 2.0 - USBC_W / 2.0,
USBC_Z))
# USB-C opening
base = base.cut(
rounded_slot(
WALL * 3,
USBC_W,
USBC_H,
-WALL,
W / 2 - USBC_W / 2,
USBC_Z
)
)
# 7. Battery bay
bot_shell = bot_shell.cut(box(BAT_L, BAT_W, 3.0, BAT_X, BAT_Y, WALL))
cy0 = BAT_Y + BAT_W / 2.0 - BAT_CLIP_Y / 2.0
bot_shell = bot_shell.fuse(box(2.0, BAT_CLIP_Y, BAT_H * 0.55, BAT_X - 2.0, cy0, WALL))
bot_shell = bot_shell.fuse(box(2.0, BAT_CLIP_Y, BAT_H * 0.55, BAT_X + BAT_L, cy0, WALL))
# Battery recess
base = base.cut(
box(BAT_L, BAT_W, 3.0,
BAT_X, BAT_Y, WALL)
)
# ═════════════════════════════════════════════════════════════════════════════
# TOP SHELL (Z = SPLIT_Z → H, open on bottom, closed ceiling at H)
# ═════════════════════════════════════════════════════════════════════════════
top_h = H - SPLIT_Z # = 7.5 mm
clip_y_start = BAT_Y + BAT_W / 2 - BAT_CLIP_Y / 2
# 1. Outer solid spans SPLIT_Z → H
top_outer = box(L, W, top_h, 0, 0, SPLIT_Z)
top_outer = fillet_vert(top_outer, CR, min_len=top_h * 0.4)
base = base.fuse(
box(2.0, BAT_CLIP_Y, BAT_H * 0.55,
BAT_X - 2.0, clip_y_start, WALL)
)
# Fillet the long horizontal edges of the top shell —
# the top edges (Z≈H) are the ones selected in blue in the user's screenshot.
try:
th_edges = []
for e in top_outer.Edges:
if len(e.Vertexes) != 2:
continue
v0, v1 = e.Vertexes[0], e.Vertexes[1]
dx = abs(v0.X - v1.X)
dz = abs(v0.Z - v1.Z)
dy = abs(v0.Y - v1.Y)
# Long edge in X, horizontal, on a long side face —
# ONLY at Z≈H (top face). Exclude Z≈SPLIT_Z (the join edge).
z_mid = (v0.Z + v1.Z) / 2.0
if dx > L * 0.5 and dz < 0.5 and dy < 0.5 and z_mid > H - 1.0:
th_edges.append(e)
if th_edges:
top_outer = top_outer.makeFillet(EDGE_FILLET, th_edges)
print(f"Top shell: filleted {len(th_edges)} horizontal edge(s) R={EDGE_FILLET}")
else:
print("Top shell: no horizontal edges found — skipped")
except Exception as exc:
print(f"Top shell horizontal fillet skipped: {exc}")
base = base.fuse(
box(2.0, BAT_CLIP_Y, BAT_H * 0.55,
BAT_X + BAT_L, clip_y_start, WALL)
)
# 2. Inner cavity: side walls WALL thick, CEILING at H-WALL (WALL-thick roof),
# FLOOR open (starts at SPLIT_Z — nothing blocks the bottom opening).
# Cavity box: X from WALL→L-WALL, Y from WALL→W-WALL, Z from SPLIT_Z→H-WALL
top_cav_lx = L - WALL * 2
top_cav_ly = W - WALL * 2
top_cav_lz = top_h - WALL # = 7.5 - 3.5 = 4.0 mm interior height
top_inner = box(top_cav_lx, top_cav_ly, top_cav_lz,
WALL, WALL, SPLIT_Z) # starts exactly at SPLIT_Z
top_shell = top_outer.cut(top_inner)
# Circular notch in back wall - centred on base
notch_cz = rail_z + LID_H
base = base.cut(circular_notch(L, W / 2, notch_cz, NOTCH_R, NOTCH_DEPTH))
# 3. Groove at the bottom of the top shell, starting at SPLIT_Z
# The groove is a frame-shaped recess cut into the inner face of the walls.
# It goes from Z=SPLIT_Z up to Z=SPLIT_Z+GROOVE_H.
# Width = GROOVE_T (slightly wider than tongue).
g_slab = box(top_cav_lx, top_cav_ly, GROOVE_H,
WALL, WALL, SPLIT_Z)
g_cut = box(top_cav_lx - GROOVE_T*2, top_cav_ly - GROOVE_T*2, GROOVE_H + 1,
WALL + GROOVE_T, WALL + GROOVE_T, SPLIT_Z - 0.5)
groove = g_slab.cut(g_cut)
top_shell = top_shell.cut(groove)
# Lid
TAB_W = RAIL_D - TOL + 0.5
LID_L = L - WALL * 2 - TOL
LID_EXTRA_TOL = 0.5
# 4. Button aperture — brute-force: run cylinder from Z=SPLIT_Z to Z=H+2.
# It will punch through the ceiling regardless of any topology.
top_shell = top_shell.cut(
cyl(BTN_HOLE_R, H - SPLIT_Z + 2, BTN_X, BTN_CY, SPLIT_Z))
LID_W = (
W - WALL * 2
- (TOL + LID_EXTRA_TOL) * 2
+ TAB_W * 2
)
# 5. Button PCB shelf frame
shelf_ox = BTN_X - BPCB_L / 2.0
shelf_oy = BTN_CY - BPCB_W / 2.0
shelf_h = 1.5
shelf_slab = box(BPCB_L + SHELF_WALL*2, BPCB_W + SHELF_WALL*2, shelf_h,
shelf_ox - SHELF_WALL, shelf_oy - SHELF_WALL,
PCB_BOT_Z - shelf_h)
shelf_hole = box(BPCB_L, BPCB_W, shelf_h + 2.0,
shelf_ox, shelf_oy, PCB_BOT_Z - shelf_h - 1.0)
shelf = shelf_slab.cut(shelf_hole)
if floor_top_shell < PCB_BOT_Z < H - WALL:
top_shell = top_shell.fuse(shelf)
lid_y0 = WALL + TOL + LID_EXTRA_TOL - TAB_W
lid = box(LID_L, LID_W, LID_H, 0, lid_y0, 0)
# 6. Screw posts on top-shell floor
if POST_H > 0.5:
for py in [BTN_CY - POST_SEP/2.0, BTN_CY + POST_SEP/2.0]:
p = cyl(POST_R, POST_H, BTN_X, py, floor_top_shell)
ph = cyl(POST_IR, POST_H + 1.0, BTN_X, py, floor_top_shell)
top_shell = top_shell.fuse(p)
top_shell = top_shell.cut(ph)
lid.translate(Base.Vector(WALL + TOL, 0, rail_z))
# ═════════════════════════════════════════════════════════════════════════════
# BUTTON CAP (separate printed part)
#
# Geometry at origin:
# Shaft: Z = 0 (bottom/inner) → Z = CAP_SHAFT_H = WALL (top/flush)
# Rim: Z = -CAP_RIM_H → Z = 0 (hangs inside cavity)
# Nubbin: Z = -CAP_RIM_H-NUBBIN_H → Z = -CAP_RIM_H
#
# Placed so shaft top = H → flush with top face.
# Rim is entirely inside the cavity. No part protrudes above H.
# ═════════════════════════════════════════════════════════════════════════════
cap_shaft = cyl(CAP_SHAFT_R, CAP_SHAFT_H)
cap_rim = cyl(CAP_RIM_R, CAP_RIM_H, 0, 0, -CAP_RIM_H)
cap_nub = cyl(NUBBIN_R, NUBBIN_H, 0, 0, -CAP_RIM_H - NUBBIN_H)
cap_raw = cap_shaft.fuse(cap_rim).fuse(cap_nub)
# No fillet on cap top rim — cap sits flush inside aperture so a fillet
# would create a visible chamfer ring against the hole edge.
cap_placed = cap_raw.copy()
cap_placed.translate(Base.Vector(BTN_X, BTN_CY, H - CAP_SHAFT_H))
# Final objects
base_obj = doc.addObject("Part::Feature", "Pointer_Base")
base_obj.Shape = base
base_obj.ViewObject.ShapeColor = (0.12, 0.12, 0.14)
# ═════════════════════════════════════════════════════════════════════════════
# REGISTER OBJECTS
# ═════════════════════════════════════════════════════════════════════════════
bot_obj = doc.addObject("Part::Feature", "Shell_Bottom")
bot_obj.Shape = bot_shell
bot_obj.ViewObject.ShapeColor = (0.12, 0.12, 0.14)
bot_obj.ViewObject.Transparency = 0
lid_obj = doc.addObject("Part::Feature", "Pointer_Lid")
lid_obj.Shape = lid
lid_obj.ViewObject.ShapeColor = (0.28, 0.28, 0.34)
lid_obj.ViewObject.Transparency = 25
top_obj = doc.addObject("Part::Feature", "Shell_Top")
top_obj.Shape = top_shell
top_obj.ViewObject.ShapeColor = (0.20, 0.20, 0.26)
top_obj.ViewObject.Transparency = 0
cap_obj = doc.addObject("Part::Feature", "Button_Cap")
cap_obj.Shape = cap_placed
cap_obj.ViewObject.ShapeColor = (0.80, 0.80, 0.86)
cap_obj.ViewObject.Transparency = 0
doc.recompute()
Gui.activeDocument().activeView().viewIsometric()
Gui.SendMsgToActiveView("ViewFit")
Gui.SendMsgToActiveView("ViewFit")
# ═════════════════════════════════════════════════════════════════════════════
# SUMMARY
# ═════════════════════════════════════════════════════════════════════════════
print("=" * 62)
print("IMU Pointer v7")
print("=" * 62)
print(f"Body: {L:.0f} × {W:.0f} mm")
print(f"Height: {H:.0f} mm uniform (no taper)")
print(f"Wall: {WALL:.1f} mm CR = {CR:.1f} mm Edge fillet = {EDGE_FILLET:.1f} mm")
print(f"Split Z: {SPLIT_Z:.1f} mm "
f"(USB-C top = {USBC_Z + USBC_H:.1f} mm)")
print(f"Top shell interior height: {top_cav_lz:.1f} mm (Z {SPLIT_Z:.1f} → {H - WALL:.1f})")
print()
print(f"Tongue H/T: {TONGUE_H:.1f} / {TONGUE_T:.1f} mm")
print(f"Groove H/T: {GROOVE_H:.2f} / {GROOVE_T:.2f} mm")
print()
print(f"Button hole: ⌀{BTN_HOLE_R*2:.0f} mm X={BTN_X} Y={BTN_CY:.0f}")
print(f"Cap shaft: ⌀{CAP_SHAFT_R*2:.1f} mm × {CAP_SHAFT_H:.1f} mm (flush, Z {H-WALL:.1f}→{H:.1f})")
print(f"Cap rim: ⌀{CAP_RIM_R*2:.0f} mm × {CAP_RIM_H:.1f} mm (retention, below top face)")
print()
print(f"PCB top Z: {PCB_TOP_Z:.2f} mm (above split floor {floor_top_shell:.1f} mm)")
print(f"PCB bot Z: {PCB_BOT_Z:.2f} mm")
print(f"Post H: {POST_H:.2f} mm ⌀{POST_OD:.0f}/{POST_ID:.1f} mm sep={POST_SEP:.0f} mm c-to-c")
print()
print(f"Switch stack: body={SWITCH_BODY_H} + stem={STEM_H} + gap={GAP} mm")
print(" Adjust SWITCH_BODY_H / STEM_H if your Omron differs.")
print("=" * 62)
+4 -2
View File
@@ -25,8 +25,10 @@ upload_speed = 115200
; Uncomment and set the correct port if auto-detect fails:
; upload_port = COM3
; Build scripts
extra_scripts = pre:scripts/git_hash.py
; Build scripts
; git_hash.py - injects short git hash into firmware + web/version.js
; generate_dfu.py - generates firmware_dfu.zip for OTA upload (requires adafruit-nrfutil)
extra_scripts = pre:scripts/git_hash.py, post:scripts/generate_dfu.py
; Build flags
build_flags =
+53
View File
@@ -0,0 +1,53 @@
"""
PlatformIO post-build script: generates a Nordic DFU package (.zip) from the
built firmware .hex using adafruit-nrfutil.
The resulting firmware_dfu.zip can be uploaded to the device via:
- nRF Connect mobile app (iOS / Android) after triggering OTA mode
- nRF Connect for Desktop
- adafruit-nrfutil over BLE (advanced)
Trigger OTA mode on the device:
- Send BLE command 0x02 to cfgCommand (0x1236), OR
- Type 'o' in the serial monitor
Usage: referenced from platformio.ini as:
extra_scripts = pre:scripts/git_hash.py, post:scripts/generate_dfu.py
"""
import subprocess, os
Import("env") # noqa: F821 - PlatformIO injects this
def generate_dfu_package(source, target, env):
build_dir = env.subst("$BUILD_DIR")
project_dir = env.subst("$PROJECT_DIR")
hex_path = os.path.join(build_dir, "firmware.hex")
if not os.path.exists(hex_path):
print(f"[generate_dfu] firmware.hex not found at {hex_path}, skipping")
return
out_path = os.path.join(project_dir, "firmware_dfu.zip")
try:
result = subprocess.run(
[
"adafruit-nrfutil", "dfu", "genpkg",
"--dev-type", "0x0052", # nRF52840
"--application", hex_path,
out_path,
],
capture_output=True,
text=True,
)
if result.returncode == 0:
size_kb = os.path.getsize(out_path) / 1024
print(f"[generate_dfu] DFU package ready: firmware_dfu.zip ({size_kb:.1f} KB)")
print(f"[generate_dfu] Upload with nRF Connect after sending OTA command (0x02) via BLE")
else:
print(f"[generate_dfu] adafruit-nrfutil error: {result.stderr.strip()}")
except FileNotFoundError:
print("[generate_dfu] adafruit-nrfutil not found - skipping DFU package generation")
print("[generate_dfu] Install with: pip install adafruit-nrfutil")
env.AddPostAction("$BUILD_DIR/firmware.hex", generate_dfu_package) # noqa: F821
+4 -11
View File
@@ -77,9 +77,6 @@ void pushConfigBlob() {
b.tapFreezeEnabled = cfg.tapFreezeEnabled;
b.jerkThreshold = cfg.jerkThreshold;
b.featureFlags = cfg.featureFlags;
b.btnLeftPin = cfg.btnLeftPin;
b.btnRightPin = cfg.btnRightPin;
b.btnMiddlePin = cfg.btnMiddlePin;
cfgBlob.write((uint8_t*)&b, sizeof(b));
}
#endif
@@ -125,13 +122,6 @@ void onConfigBlobWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l)
cfg.tapFreezeEnabled = b->tapFreezeEnabled ? 1 : 0;
if (b->jerkThreshold >= 100.0f && b->jerkThreshold <= 50000.0f) cfg.jerkThreshold = b->jerkThreshold;
cfg.featureFlags = b->featureFlags & (FLAG_TAP_ENABLED | FLAG_TEMP_COMP_ENABLED | FLAG_AUTO_RECAL_ENABLED);
// btnXPin: accept BTN_PIN_NONE (0xFF) or a valid Arduino pin number (0-10 = D0-D10)
cfg.btnLeftPin = (b->btnLeftPin <= 10 || b->btnLeftPin == BTN_PIN_NONE) ? b->btnLeftPin : BTN_PIN_NONE;
cfg.btnRightPin = (b->btnRightPin <= 10 || b->btnRightPin == BTN_PIN_NONE) ? b->btnRightPin : BTN_PIN_NONE;
cfg.btnMiddlePin = (b->btnMiddlePin <= 10 || b->btnMiddlePin == BTN_PIN_NONE) ? b->btnMiddlePin : BTN_PIN_NONE;
#ifdef FEATURE_PHYSICAL_BUTTONS
setupPhysicalButtons(); // reconfigure pins immediately (no restart needed)
#endif
saveConfig();
Serial.print("[CFG] Written - sens="); Serial.print(cfg.sensitivity,0);
Serial.print(" dz="); Serial.print(cfg.deadZone,3);
@@ -143,6 +133,9 @@ void onCommandWrite(uint16_t h, BLECharacteristic* c, uint8_t* d, uint16_t l) {
if (l < 1) return;
if (d[0] == 0x01) pendingCal = true;
if (d[0] == 0xFF) pendingReset = true;
#ifdef FEATURE_OTA
if (d[0] == 0x02) pendingOTA = true;
#endif
}
#ifdef FEATURE_IMU_STREAM
@@ -163,7 +156,7 @@ void setupConfigService() {
cfgBlob.begin();
pushConfigBlob();
cfgCommand.setProperties(CHR_PROPS_WRITE);
cfgCommand.setProperties(CHR_PROPS_WRITE | CHR_PROPS_WRITE_WO_RESP);
cfgCommand.setPermission(SECMODE_OPEN, SECMODE_OPEN);
cfgCommand.setFixedLen(1);
cfgCommand.setWriteCallback(onCommandWrite);
+11 -11
View File
@@ -13,19 +13,19 @@ void setupPhysicalButtons() {
if (physBtnMask && Bluefruit.connected()) { blehid.mouseButtonRelease(); }
physBtnMask = 0;
if (cfg.btnLeftPin != BTN_PIN_NONE) pinMode(cfg.btnLeftPin, INPUT_PULLUP);
if (cfg.btnRightPin != BTN_PIN_NONE) pinMode(cfg.btnRightPin, INPUT_PULLUP);
if (cfg.btnMiddlePin != BTN_PIN_NONE) pinMode(cfg.btnMiddlePin, INPUT_PULLUP);
if (BTN_LEFT_PIN != BTN_PIN_NONE) pinMode(BTN_LEFT_PIN, INPUT_PULLUP);
if (BTN_RIGHT_PIN != BTN_PIN_NONE) pinMode(BTN_RIGHT_PIN, INPUT_PULLUP);
if (BTN_MIDDLE_PIN != BTN_PIN_NONE) pinMode(BTN_MIDDLE_PIN, INPUT_PULLUP);
bool any = (cfg.btnLeftPin != BTN_PIN_NONE) || (cfg.btnRightPin != BTN_PIN_NONE)
|| (cfg.btnMiddlePin != BTN_PIN_NONE);
bool any = (BTN_LEFT_PIN != BTN_PIN_NONE) || (BTN_RIGHT_PIN != BTN_PIN_NONE)
|| (BTN_MIDDLE_PIN != BTN_PIN_NONE);
if (any) {
Serial.print("[BTN] L=");
cfg.btnLeftPin == BTN_PIN_NONE ? Serial.print("--") : Serial.print(cfg.btnLeftPin);
BTN_LEFT_PIN == BTN_PIN_NONE ? Serial.print("--") : Serial.print(BTN_LEFT_PIN);
Serial.print(" R=");
cfg.btnRightPin == BTN_PIN_NONE ? Serial.print("--") : Serial.print(cfg.btnRightPin);
BTN_RIGHT_PIN == BTN_PIN_NONE ? Serial.print("--") : Serial.print(BTN_RIGHT_PIN);
Serial.print(" M=");
cfg.btnMiddlePin == BTN_PIN_NONE ? Serial.print("--") : Serial.print(cfg.btnMiddlePin);
BTN_MIDDLE_PIN == BTN_PIN_NONE ? Serial.print("--") : Serial.print(BTN_MIDDLE_PIN);
Serial.println();
}
}
@@ -37,9 +37,9 @@ void processPhysicalButtons() {
if (!Bluefruit.connected()) return;
uint8_t newMask = 0;
if (cfg.btnLeftPin != BTN_PIN_NONE && digitalRead(cfg.btnLeftPin) == LOW) newMask |= MOUSE_BUTTON_LEFT;
if (cfg.btnRightPin != BTN_PIN_NONE && digitalRead(cfg.btnRightPin) == LOW) newMask |= MOUSE_BUTTON_RIGHT;
if (cfg.btnMiddlePin != BTN_PIN_NONE && digitalRead(cfg.btnMiddlePin) == LOW) newMask |= MOUSE_BUTTON_MIDDLE;
if (BTN_LEFT_PIN != BTN_PIN_NONE && digitalRead(BTN_LEFT_PIN) == LOW) newMask |= MOUSE_BUTTON_LEFT;
if (BTN_RIGHT_PIN != BTN_PIN_NONE && digitalRead(BTN_RIGHT_PIN) == LOW) newMask |= MOUSE_BUTTON_RIGHT;
if (BTN_MIDDLE_PIN != BTN_PIN_NONE && digitalRead(BTN_MIDDLE_PIN) == LOW) newMask |= MOUSE_BUTTON_MIDDLE;
if (newMask != physBtnMask) {
physBtnMask = newMask;
+11 -9
View File
@@ -11,6 +11,7 @@
#define FEATURE_BATTERY_MONITOR
#define FEATURE_BOOT_LOOP_DETECT
#define FEATURE_PHYSICAL_BUTTONS
#define FEATURE_OTA
// Debug
// #define DEBUG
@@ -56,8 +57,12 @@
#define CONFIG_FILENAME "/imu_mouse_cfg.bin"
#define CONFIG_MAGIC 0xDEAD123EUL
// Physical button sentinel
#define BTN_PIN_NONE 0xFF // Stored in btn*Pin when that button is disabled
// Physical button pin assignments (hardcoded - set to 0xFF to disable a button)
// Valid pin numbers: 0-10 (Arduino D0-D10 on XIAO nRF52840 Sense)
#define BTN_PIN_NONE 0xFF
#define BTN_LEFT_PIN BTN_PIN_NONE // e.g. 0 for D0
#define BTN_RIGHT_PIN BTN_PIN_NONE // e.g. 1 for D1
#define BTN_MIDDLE_PIN BTN_PIN_NONE // e.g. 2 for D2
// Runtime feature-override flags (cfg.featureFlags bitmask)
// These mirror the compile-time FEATURE_* defines but can be toggled at runtime
@@ -98,9 +103,6 @@ struct Config {
float jerkThreshold; // jerk² threshold for tap-freeze detection
uint8_t tapFreezeEnabled; // 1 = enable jerk-based cursor freeze during taps
uint8_t featureFlags; // bitmask of FLAG_* - runtime feature overrides
uint8_t btnLeftPin; // BTN_PIN_NONE or Arduino pin number (0-10 = D0-D10)
uint8_t btnRightPin;
uint8_t btnMiddlePin;
};
extern Config cfg;
extern const Config CFG_DEFAULTS;
@@ -120,11 +122,8 @@ struct __attribute__((packed)) ConfigBlob {
uint8_t tapFreezeEnabled; // [19] 1 = enable jerk-based cursor freeze during taps
float jerkThreshold; // [20] jerk² tap-freeze threshold
uint8_t featureFlags; // [24] FLAG_* bitmask - runtime feature overrides
uint8_t btnLeftPin; // [25] BTN_PIN_NONE or Arduino pin (0-10 = D0-D10)
uint8_t btnRightPin; // [26]
uint8_t btnMiddlePin; // [27]
};
static_assert(sizeof(ConfigBlob) == 28, "ConfigBlob must be 28 bytes");
static_assert(sizeof(ConfigBlob) == 25, "ConfigBlob must be 25 bytes");
// TelemetryPacket (24 bytes)
#ifdef FEATURE_TELEMETRY
@@ -207,6 +206,9 @@ extern float cachedTempC;
extern bool pendingCal;
extern bool pendingReset;
#ifdef FEATURE_OTA
extern bool pendingOTA;
#endif
extern ChargeStatus lastChargeStatus;
extern int idleFrames;
extern unsigned long idleStartMs;
+36 -3
View File
@@ -47,8 +47,7 @@ Config cfg;
const Config CFG_DEFAULTS = {
CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW,
/*tapThreshold=*/12, /*tapAction=*/TAP_ACTION_LEFT, /*tapKey=*/0, /*tapMod=*/0,
/*jerkThreshold=*/2000.0f, /*tapFreezeEnabled=*/1, /*featureFlags=*/FLAG_ALL_DEFAULT,
/*btnLeftPin=*/BTN_PIN_NONE, /*btnRightPin=*/BTN_PIN_NONE, /*btnMiddlePin=*/BTN_PIN_NONE
/*jerkThreshold=*/2000.0f, /*tapFreezeEnabled=*/1, /*featureFlags=*/FLAG_ALL_DEFAULT
};
// Telemetry definition
@@ -59,6 +58,8 @@ TelemetryPacket telem = {};
// Tuning constants
const float ALPHA = 0.96f;
const int LOOP_RATE_MS = 10;
const float SMOOTH_LOW_RPS = 0.15f; // below this → heavy EMA smoothing (~8°/s)
const float SMOOTH_HIGH_RPS = 0.50f; // above this → no smoothing (~29°/s)
const int BIAS_SAMPLES = 200;
const int IDLE_FRAMES = 150;
const unsigned long BATT_REPORT_MS = 20000;
@@ -116,6 +117,9 @@ float cachedTempC = 25.0f;
uint32_t loopStalls = 0; // loop iterations where dt > 20ms (behind schedule)
bool pendingCal = false;
bool pendingReset = false;
#ifdef FEATURE_OTA
bool pendingOTA = false;
#endif
// Jerk-based shock detection - freeze cursor during tap impacts, doesn't work well yet!
unsigned long shockFreezeUntil = 0;
@@ -298,10 +302,28 @@ void loop() {
char cmd = Serial.read();
if (cmd == 'c') { Serial.println("[SERIAL] Calibrate"); pendingCal = true; }
if (cmd == 'r') { Serial.println("[SERIAL] Reset"); pendingReset = true; }
#ifdef FEATURE_OTA
if (cmd == 'o') { Serial.println("[SERIAL] OTA DFU"); pendingOTA = true; }
#endif
}
if (pendingCal) { pendingCal = false; calibrateGyroBias(); prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); }
if (pendingReset) { pendingReset = false; factoryReset(); }
#ifdef FEATURE_OTA
if (pendingOTA) {
pendingOTA = false;
Serial.println("[OTA] Disconnecting BLE and entering bootloader DFU mode...");
Serial.flush();
// Gracefully close the BLE connection first so the host can detect the
// disconnect and be ready to see DfuTarg advertise after the reboot.
if (Bluefruit.connected()) {
Bluefruit.disconnect(0);
delay(300);
}
delay(200);
enterOTADfu(); // Adafruit nRF52 core: sets GPREGRET correctly and resets into bootloader OTA mode
}
#endif
// Heartbeat LED
if (now - lastHeartbeat >= HEARTBEAT_MS) {
@@ -425,11 +447,15 @@ void loop() {
int8_t moveX = 0, moveY = 0;
uint8_t flags = 0;
static float smoothX = 0.0f, smoothY = 0.0f;
if (shocked) {
// Shock freeze - discard accumulated sub-pixel motion and suppress output
smoothX = smoothY = 0.0f;
accumX = accumY = 0.0f;
flags |= 0x08; // bit3 = shock freeze active
} else if (idle) {
smoothX = smoothY = 0.0f;
accumX = accumY = 0.0f;
flags |= 0x01;
} else {
@@ -437,7 +463,14 @@ void loop() {
float rawY = applyAcceleration(applyCurve(-fPitch * cfg.sensitivity * dt));
if (cfg.axisFlip & 0x01) rawX = -rawX;
if (cfg.axisFlip & 0x02) rawY = -rawY;
accumX += rawX; accumY += rawY;
// Tiered velocity smoothing: heavy EMA when nearly still, none when fast.
// Thresholds are in rad/s (angular rate), independent of sensitivity setting.
float speed = sqrtf(fYaw*fYaw + fPitch*fPitch);
float alpha = (speed < SMOOTH_LOW_RPS) ? 0.25f :
(speed < SMOOTH_HIGH_RPS) ? 0.65f : 1.00f;
smoothX = smoothX * (1.0f - alpha) + rawX * alpha;
smoothY = smoothY * (1.0f - alpha) + rawY * alpha;
accumX += smoothX; accumY += smoothY;
moveX = (int8_t)constrain((int)accumX, -127, 127);
moveY = (int8_t)constrain((int)accumY, -127, 127);
accumX -= moveX; accumY -= moveY;
+46 -7
View File
@@ -34,6 +34,17 @@ static volatile bool pendingWakeRecal = false; // set only when recal is also
// has been off long enough for thermal drift to matter (~5 minutes).
static constexpr unsigned long RECAL_AFTER_LP_MS = 5UL * 60UL * 1000UL;
// Post-wake stillness gate: don't calibrate while the device is moving.
// Each axis of the gyro must stay below RECAL_STILL_DPS for RECAL_STILL_FRAMES
// consecutive loop ticks before calibration fires. If the device keeps moving
// past RECAL_WAIT_MAX_MS, recal is skipped and the pre-sleep bias is kept.
static constexpr float RECAL_STILL_DPS = 10.0f; // deg/s per axis
static constexpr uint8_t RECAL_STILL_FRAMES = 10; // ~100 ms at 10 ms/frame
static constexpr unsigned long RECAL_WAIT_MAX_MS = 3000; // give up after 3 s
static uint8_t recalStillFrames = 0;
static unsigned long recalLastWarnMs = 0;
// I2C helpers - Wire1 at 0x6A (SA0 LOW on XIAO nRF52840 Sense)
static uint8_t lsmRead(uint8_t reg) {
Wire1.beginTransmission(LSM_ADDR);
@@ -238,15 +249,43 @@ bool sleepManagerUpdate(unsigned long nowMs, bool idle, bool bleConnected) {
}
}
// Gyro settling after wake
// Post-wake recalibration — gated on device being still
if (pendingWakeRecal) {
if (nowMs - wakeSettleMs >= 120) {
pendingWakeRecal = false;
wakeSettleMs = 0;
extern void calibrateGyroBias();
calibrateGyroBias();
Serial.println("[SLEEP] Post-wake recal done");
if (nowMs - wakeSettleMs < 120) return true; // initial hardware settle
// Sample gyro magnitude; each axis must be below threshold
float gx = fabsf(imu.readFloatGyroX());
float gy = fabsf(imu.readFloatGyroY());
float gz = fabsf(imu.readFloatGyroZ());
bool still = (gx < RECAL_STILL_DPS && gy < RECAL_STILL_DPS && gz < RECAL_STILL_DPS);
if (!still) {
recalStillFrames = 0;
// Rate-limited log so we don't flood serial while waiting
if (nowMs - recalLastWarnMs >= 500) {
recalLastWarnMs = nowMs;
Serial.print("[SLEEP] Waiting for still: gx="); Serial.print(gx, 1);
Serial.print(" gy="); Serial.print(gy, 1);
Serial.print(" gz="); Serial.println(gz, 1);
}
if (nowMs - wakeSettleMs >= RECAL_WAIT_MAX_MS) {
// Device never settled — keep pre-sleep bias rather than corrupt it
pendingWakeRecal = false;
recalStillFrames = 0;
recalLastWarnMs = 0;
Serial.println("[SLEEP] Recal skipped — device still moving after timeout");
}
return true;
}
// Device is still — accumulate consecutive still frames
if (++recalStillFrames < RECAL_STILL_FRAMES) return true;
pendingWakeRecal = false;
recalStillFrames = 0;
recalLastWarnMs = 0;
calibrateGyroBias();
Serial.println("[SLEEP] Post-wake recal done");
return true;
}
+35 -48
View File
@@ -19,7 +19,7 @@ const FLAG_ALL_DEFAULT = FLAG_TAP_ENABLED | FLAG_TEMP_COMP_ENABLED | FLAG
const config = { sensitivity:600, deadZone:0.06, accelStrength:0.08, curve:0, axisFlip:0, chargeMode:0,
tapThreshold:12, tapAction:0, tapKey:0, tapMod:0, tapFreezeEnabled:1, jerkThreshold:2000,
featureFlags:FLAG_ALL_DEFAULT,
btnLeftPin:0xFF, btnRightPin:0xFF, btnMiddlePin:0xFF };
};
let device=null, server=null, chars={}, userDisconnected=false;
let currentChargeStatus=0, currentBattPct=null, currentBattVoltage=null;
@@ -247,13 +247,6 @@ async function readConfigBlob() {
} else {
config.featureFlags = FLAG_ALL_DEFAULT; // old firmware - assume all on
}
if (view.byteLength >= 28) {
config.btnLeftPin = view.getUint8(25);
config.btnRightPin = view.getUint8(26);
config.btnMiddlePin = view.getUint8(27);
} else {
config.btnLeftPin = config.btnRightPin = config.btnMiddlePin = 0xFF; // disabled
}
applyConfigToUI();
log(`Config loaded - sens=${config.sensitivity.toFixed(0)} dz=${config.deadZone.toFixed(3)} tapThr=${config.tapThreshold}`,'ok');
} catch(e) { log(`Config read error: ${e.message}`,'err'); }
@@ -285,38 +278,8 @@ function applyConfigToUI() {
document.getElementById('capTapEnabled').checked = !!(config.featureFlags & FLAG_TAP_ENABLED);
document.getElementById('capTempComp').checked = !!(config.featureFlags & FLAG_TEMP_COMP_ENABLED);
document.getElementById('capAutoRecal').checked = !!(config.featureFlags & FLAG_AUTO_RECAL_ENABLED);
document.getElementById('btnLeftPin').value = config.btnLeftPin;
document.getElementById('btnRightPin').value = config.btnRightPin;
document.getElementById('btnMiddlePin').value = config.btnMiddlePin;
updatePinDiagram();
}
// XIAO pin diagram
function updatePinDiagram() {
const st = getComputedStyle(document.documentElement);
const COL_L = st.getPropertyValue('--ok').trim();
const COL_R = st.getPropertyValue('--accent2').trim();
const COL_M = st.getPropertyValue('--accent').trim();
const DEF_F = '#0c1828', DEF_S = '#162234';
for (let i = 0; i <= 10; i++) {
const el = document.getElementById(`xiaoPin${i}`);
if (el) { el.setAttribute('fill', DEF_F); el.setAttribute('stroke', DEF_S); }
}
const apply = (pin, col) => {
if (pin > 10) return;
const el = document.getElementById(`xiaoPin${pin}`);
if (el) { el.setAttribute('fill', col); el.setAttribute('stroke', col); }
};
const l = parseInt(document.getElementById('btnLeftPin').value, 10);
const r = parseInt(document.getElementById('btnRightPin').value, 10);
const m = parseInt(document.getElementById('btnMiddlePin').value, 10);
if (l <= 10) apply(l, COL_L);
if (r <= 10) apply(r, COL_R);
if (m <= 10) apply(m, COL_M);
}
let _writeConfigTimer = null;
function writeConfigBlob() {
@@ -344,11 +307,7 @@ async function _doWriteConfigBlob() {
| (document.getElementById('capAutoRecal').checked ? FLAG_AUTO_RECAL_ENABLED : 0);
// config.curve, config.chargeMode, config.tapAction, config.tapKey updated directly
config.btnLeftPin = parseInt(document.getElementById('btnLeftPin').value, 10);
config.btnRightPin = parseInt(document.getElementById('btnRightPin').value, 10);
config.btnMiddlePin = parseInt(document.getElementById('btnMiddlePin').value, 10);
const buf = new ArrayBuffer(28);
const buf = new ArrayBuffer(25);
const view = new DataView(buf);
view.setFloat32(0, config.sensitivity, true);
view.setFloat32(4, config.deadZone, true);
@@ -363,9 +322,6 @@ async function _doWriteConfigBlob() {
view.setUint8(19, config.tapFreezeEnabled);
view.setFloat32(20, config.jerkThreshold, true);
view.setUint8(24, config.featureFlags);
view.setUint8(25, config.btnLeftPin);
view.setUint8(26, config.btnRightPin);
view.setUint8(27, config.btnMiddlePin);
try {
await gattWrite(chars.configBlob, buf);
@@ -661,7 +617,7 @@ function setStatus(state) {
pill.className='status-pill '+state;
document.body.className=state;
const cBtn=document.getElementById('connectBtn'), dBtn=document.getElementById('disconnectBtn');
const inputs=document.querySelectorAll('input[type=range],.seg-btn,.toggle input,.cmd-btn,#tapKeyHex,.mod-btn input,.pin-select');
const inputs=document.querySelectorAll('input[type=range],.seg-btn,.toggle input,.cmd-btn,#tapKeyHex,.mod-btn input');
if (state==='connected') {
cBtn.style.display='none'; dBtn.style.display='';
inputs.forEach(el=>el.disabled=false);
@@ -1043,7 +999,6 @@ function applyTheme(t) {
localStorage.setItem('theme', t);
if (!chars.imuStream) drawInitState();
orientUpdateColors();
updatePinDiagram();
}
(function(){
const saved = localStorage.getItem('theme') ?? 'auto';
@@ -1060,3 +1015,35 @@ if (!navigator.bluetooth) {
} else {
log('Web Bluetooth ready. Click CONNECT to pair your IMU Mouse.','info');
}
// ─────────────────────────────────────────────────────────────────────────────
// OTA firmware update
//
// The OTAFIX bootloader uses Nordic Legacy DFU (service 00001530-...) which is
// blocklisted in Chrome's Web Bluetooth implementation. Browser-side upload is
// therefore not possible without special flags or a native app wrapper.
//
// What the UI does instead:
// • "Enter DFU Mode" sends command 0x02 via BLE → device reboots as XIAO_DFU
// • User then uploads firmware_dfu.zip via nRF Connect (mobile or desktop)
// ─────────────────────────────────────────────────────────────────────────────
function otaLog(msg, type = 'info') {
log('[OTA] ' + msg, type);
const el = document.getElementById('otaStatus');
if (el) { el.textContent = msg; el.className = 'ota-status' + (type !== 'info' ? ' ota-' + type : ''); }
}
// Send command 0x02 → firmware reboots into XIAO_DFU bootloader mode.
// User then uploads firmware_dfu.zip via nRF Connect.
async function sendOTATrigger() {
if (!chars.command) { otaLog('Not connected', 'err'); return; }
document.getElementById('btnOTA').disabled = true;
try {
await chars.command.writeValueWithResponse(new Uint8Array([0x02]));
otaLog('Device rebooting into DFU mode — connect to XIAO_DFU in nRF Connect', 'ok');
} catch (e) {
otaLog('Failed: ' + e.message, 'err');
document.getElementById('btnOTA').disabled = false;
}
}
+24 -116
View File
@@ -180,122 +180,6 @@
</div>
</div>
<div class="section-label">Physical Buttons</div>
<div class="card">
<!-- XIAO nRF52840 Sense pin diagram -->
<div class="xiao-wrap">
<svg id="xiaoSvg" viewBox="0 0 200 278" xmlns="http://www.w3.org/2000/svg" style="display:block;width:100%;max-width:200px">
<!-- USB-C connector -->
<rect x="72" y="4" width="56" height="28" rx="7" fill="#080e18" stroke="#162234" stroke-width="1.5"/>
<rect x="82" y="10" width="36" height="5" rx="1.5" fill="#0e1c30"/><rect x="82" y="18" width="36" height="5" rx="1.5" fill="#0e1c30"/>
<text x="100" y="30" text-anchor="middle" font-size="5.5" fill="#162234" font-family="Share Tech Mono,monospace">USB·C</text>
<!-- PCB board -->
<rect x="22" y="29" width="156" height="244" rx="9" fill="#080e18" stroke="#142030" stroke-width="1.5"/>
<!-- Corner holes -->
<circle cx="34" cy="41" r="3.5" fill="#040810" stroke="#0e1c2c" stroke-width="0.8"/>
<circle cx="166" cy="41" r="3.5" fill="#040810" stroke="#0e1c2c" stroke-width="0.8"/>
<circle cx="34" cy="261" r="3.5" fill="#040810" stroke="#0e1c2c" stroke-width="0.8"/>
<circle cx="166" cy="261" r="3.5" fill="#040810" stroke="#0e1c2c" stroke-width="0.8"/>
<!-- Board silk label -->
<text x="100" y="52" text-anchor="middle" font-size="5.5" fill="#142030" font-family="Share Tech Mono,monospace">XIAO nRF52840 Sense</text>
<!-- Antenna outline -->
<rect x="130" y="34" width="34" height="22" rx="2" fill="none" stroke="#0e1c2c" stroke-width="0.7" stroke-dasharray="2,2"/>
<text x="147" y="48" text-anchor="middle" font-size="5" fill="#0e1c2c" font-family="Share Tech Mono,monospace">ANT</text>
<!-- SoC -->
<rect x="60" y="78" width="80" height="72" rx="3" fill="#0c1828" stroke="#142030" stroke-width="0.8"/>
<text x="100" y="112" text-anchor="middle" font-size="7" fill="#1e3858" font-family="Share Tech Mono,monospace" font-weight="bold">nRF52840</text>
<text x="100" y="123" text-anchor="middle" font-size="5.5" fill="#102030" font-family="Share Tech Mono,monospace">HOLYIOT</text>
<!-- BGA dots (decorative) -->
<g fill="#0c1a2a"><circle cx="74" cy="94" r="1.8"/><circle cx="82" cy="94" r="1.8"/><circle cx="90" cy="94" r="1.8"/><circle cx="98" cy="94" r="1.8"/><circle cx="106" cy="94" r="1.8"/><circle cx="114" cy="94" r="1.8"/><circle cx="74" cy="102" r="1.8"/><circle cx="82" cy="102" r="1.8"/><circle cx="90" cy="102" r="1.8"/><circle cx="98" cy="102" r="1.8"/><circle cx="106" cy="102" r="1.8"/><circle cx="114" cy="102" r="1.8"/><circle cx="74" cy="140" r="1.8"/><circle cx="82" cy="140" r="1.8"/><circle cx="90" cy="140" r="1.8"/><circle cx="98" cy="140" r="1.8"/><circle cx="106" cy="140" r="1.8"/><circle cx="114" cy="140" r="1.8"/></g>
<!-- IMU chip -->
<rect x="72" y="176" width="56" height="44" rx="3" fill="#0a1420" stroke="#122030" stroke-width="0.8"/>
<text x="100" y="196" text-anchor="middle" font-size="5.5" fill="#142030" font-family="Share Tech Mono,monospace">LSM6DS3</text>
<text x="100" y="207" text-anchor="middle" font-size="5" fill="#0e1c2c" font-family="Share Tech Mono,monospace">IMU</text>
<!-- Charger IC -->
<rect x="36" y="228" width="30" height="26" rx="2" fill="#0a1420" stroke="#122030" stroke-width="0.8"/>
<text x="51" y="244" text-anchor="middle" font-size="4.5" fill="#0e1c2c" font-family="Share Tech Mono,monospace">BQ25100</text>
<!-- LED indicators -->
<circle cx="142" cy="168" r="3.5" fill="#0a1a0a" stroke="#0e180e" stroke-width="0.8"/>
<circle cx="152" cy="168" r="3.5" fill="#1a0a0a" stroke="#180e0e" stroke-width="0.8"/>
<circle cx="162" cy="168" r="3.5" fill="#0a0a1a" stroke="#0e0e18" stroke-width="0.8"/>
<text x="152" y="178" text-anchor="middle" font-size="4.5" fill="#0e1c2c" font-family="Share Tech Mono,monospace">LED</text>
<!-- Left traces -->
<g stroke="#142030" stroke-width="2"><line x1="22" y1="62" x2="9" y2="62"/><line x1="22" y1="94" x2="9" y2="94"/><line x1="22" y1="126" x2="9" y2="126"/><line x1="22" y1="158" x2="9" y2="158"/><line x1="22" y1="190" x2="9" y2="190"/><line x1="22" y1="222" x2="9" y2="222"/><line x1="22" y1="254" x2="9" y2="254"/></g>
<!-- Right traces -->
<g stroke="#142030" stroke-width="2"><line x1="178" y1="62" x2="191" y2="62"/><line x1="178" y1="94" x2="191" y2="94"/><line x1="178" y1="126" x2="191" y2="126"/><line x1="178" y1="158" x2="191" y2="158"/><line x1="178" y1="190" x2="191" y2="190"/><line x1="178" y1="222" x2="191" y2="222"/><line x1="178" y1="254" x2="191" y2="254"/></g>
<!-- Left pads D0-D6 (arduino 0-6) -->
<circle id="xiaoPin0" cx="9" cy="62" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<circle id="xiaoPin1" cx="9" cy="94" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<circle id="xiaoPin2" cx="9" cy="126" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<circle id="xiaoPin3" cx="9" cy="158" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<circle id="xiaoPin4" cx="9" cy="190" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<circle id="xiaoPin5" cx="9" cy="222" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<circle id="xiaoPin6" cx="9" cy="254" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<!-- Right pads D7-D10 (arduino 7-10) -->
<circle id="xiaoPin7" cx="191" cy="62" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<circle id="xiaoPin8" cx="191" cy="94" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<circle id="xiaoPin9" cx="191" cy="126" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<circle id="xiaoPin10" cx="191" cy="158" r="7" fill="#0c1828" stroke="#162234" stroke-width="1.5"/>
<!-- RST / GND / 3V3 (non-configurable) -->
<circle cx="191" cy="190" r="7" fill="#0c1828" stroke="#102028" stroke-width="1.5"/>
<circle cx="191" cy="222" r="7" fill="#0c1828" stroke="#201010" stroke-width="1.5"/>
<circle cx="191" cy="254" r="7" fill="#0c1828" stroke="#102010" stroke-width="1.5"/>
<!-- Left labels D0-D6 -->
<g font-size="7" font-family="Share Tech Mono,monospace" fill="#1e3858"><text x="24" y="65">D0</text><text x="24" y="97">D1</text><text x="24" y="129">D2</text><text x="24" y="161">D3</text><text x="24" y="193">D4</text><text x="24" y="225">D5</text><text x="24" y="257">D6</text></g>
<g font-size="5.5" font-family="Share Tech Mono,monospace" fill="#112030"><text x="36" y="65">A0</text><text x="36" y="97">A1</text><text x="36" y="129">A2</text><text x="36" y="161">A3</text><text x="36" y="193">SDA</text><text x="36" y="225">SCL</text><text x="36" y="257">TX</text></g>
<!-- Right labels D7-D10 -->
<g font-size="7" font-family="Share Tech Mono,monospace" text-anchor="end" fill="#1e3858"><text x="176" y="65">D7</text><text x="176" y="97">D8</text><text x="176" y="129">D9</text><text x="176" y="161">D10</text></g>
<g font-size="5.5" font-family="Share Tech Mono,monospace" text-anchor="end" fill="#112030"><text x="162" y="65">RX</text><text x="162" y="97">SCK</text><text x="162" y="129">MISO</text><text x="162" y="161">MOSI</text></g>
<!-- RST/GND/3V3 labels -->
<g font-size="7" font-family="Share Tech Mono,monospace" text-anchor="end" fill="#0e1c2c"><text x="176" y="193">RST</text></g>
<g font-size="7" font-family="Share Tech Mono,monospace" text-anchor="end" fill="#1a0808"><text x="176" y="225">GND</text></g>
<g font-size="7" font-family="Share Tech Mono,monospace" text-anchor="end" fill="#081a08"><text x="176" y="257">3V3</text></g>
</svg>
<div class="pin-legend">
<span class="pleg left">● Left</span>
<span class="pleg right">● Right</span>
<span class="pleg mid">● Middle</span>
</div>
</div>
<hr class="xiao-divider">
<div class="flip-row" style="align-items:center;padding-top:4px">
<div class="flip-label">Left Click</div>
<div class="param-desc" style="flex:1;font-size:9px;color:var(--label)">Pin wired to GND when pressed</div>
<select class="pin-select" id="btnLeftPin" onchange="updatePinDiagram();writeConfigBlob()" disabled>
<option value="255">None</option>
<option value="0">D0</option><option value="1">D1</option><option value="2">D2</option>
<option value="3">D3</option><option value="4">D4</option><option value="5">D5</option>
<option value="6">D6</option><option value="7">D7</option><option value="8">D8</option>
<option value="9">D9</option><option value="10">D10</option>
</select>
</div>
<div class="flip-row" style="align-items:center">
<div class="flip-label">Right Click</div>
<div class="param-desc" style="flex:1;font-size:9px;color:var(--label)">Pin wired to GND when pressed</div>
<select class="pin-select" id="btnRightPin" onchange="updatePinDiagram();writeConfigBlob()" disabled>
<option value="255">None</option>
<option value="0">D0</option><option value="1">D1</option><option value="2">D2</option>
<option value="3">D3</option><option value="4">D4</option><option value="5">D5</option>
<option value="6">D6</option><option value="7">D7</option><option value="8">D8</option>
<option value="9">D9</option><option value="10">D10</option>
</select>
</div>
<div class="flip-row" style="align-items:center;border-bottom:none">
<div class="flip-label">Middle Click</div>
<div class="param-desc" style="flex:1;font-size:9px;color:var(--label)">Pin wired to GND when pressed</div>
<select class="pin-select" id="btnMiddlePin" onchange="updatePinDiagram();writeConfigBlob()" disabled>
<option value="255">None</option>
<option value="0">D0</option><option value="1">D1</option><option value="2">D2</option>
<option value="3">D3</option><option value="4">D4</option><option value="5">D5</option>
<option value="6">D6</option><option value="7">D7</option><option value="8">D8</option>
<option value="9">D9</option><option value="10">D10</option>
</select>
</div>
<div style="padding:8px 0 2px;font-size:9px;color:var(--label);font-family:var(--mono)">
Pull-up built-in · wire button between chosen pin and GND
</div>
</div>
<div class="section-label">Device Commands</div>
<div class="cmd-grid">
<button class="cmd-btn calibrate" id="btnCal" onclick="sendCalibrate()" disabled>
@@ -308,6 +192,29 @@
</button>
</div>
<div class="section-label" style="margin-top:8px">Firmware Update (OTA)</div>
<div class="card ota-card" id="otaCard">
<div class="ota-notice">
<div class="ota-notice-icon"></div>
<div>
<div class="ota-notice-title">Browser OTA not available</div>
<div class="ota-notice-body">Chrome blocks the Nordic Legacy DFU service UUID used by this bootloader. Use <strong>nRF Connect</strong> (mobile or desktop) to upload firmware instead.</div>
</div>
</div>
<ol class="ota-steps">
<li>Build firmware: <code>pio run</code> → produces <code>firmware_dfu.zip</code></li>
<li>Click <strong>Enter DFU Mode</strong> below — device reboots as <em>XIAO_DFU</em></li>
<li>Open nRF Connect → connect to <em>XIAO_DFU</em> → DFU → select <code>firmware_dfu.zip</code></li>
</ol>
<div class="ota-btn-row" style="grid-template-columns:1fr">
<button class="cmd-btn ota-trigger" id="btnOTA" onclick="sendOTATrigger()" disabled>
<span class="cmd-icon"></span><span>Enter DFU Mode</span>
<span class="cmd-desc">Reboots device into XIAO_DFU so nRF Connect can upload firmware.</span>
</button>
</div>
<div class="ota-status-row"><div class="ota-status" id="otaStatus"></div></div>
</div>
<div class="section-label" style="margin-top:8px">Event Log</div>
<div class="console" id="console"></div>
@@ -365,6 +272,7 @@
</div>
</main>
<div class="overlay" id="overlay">
<div class="modal">
<h3>⚠ Factory Reset</h3>
+20 -9
View File
@@ -153,13 +153,6 @@
.col-mid { display:grid; gap:12px; }
.col-right { display:grid; gap:12px; position:sticky; top:80px; }
/* XIAO pin diagram */
.xiao-wrap { display:flex; flex-direction:column; align-items:center; padding:8px 0 14px; }
.pin-legend { display:flex; gap:20px; justify-content:center; font-family:var(--mono); font-size:9px; margin-top:10px; letter-spacing:0.08em; }
.pleg.left { color:var(--ok); }
.pleg.right { color:var(--accent2); }
.pleg.mid { color:var(--accent); }
.xiao-divider { border:none; border-top:1px solid var(--border); margin:0 -20px 12px; }
/* Responsive */
@media (max-width:1100px) {
@@ -315,6 +308,7 @@
.no-ble p { font-size:13px; color:var(--label); line-height:1.8; }
body.disconnected .card { opacity:0.45; pointer-events:none; transition:opacity 0.3s; }
body.disconnected .card.ota-card { opacity:1; pointer-events:auto; } /* OTA works when disconnected too */
body.disconnected .cmd-grid { opacity:0.45; pointer-events:none; transition:opacity 0.3s; }
.tap-key-row { display:flex; align-items:center; gap:10px; padding-top:12px; flex-wrap:wrap; }
@@ -325,11 +319,28 @@
.mod-btn input:disabled + span { opacity:0.35; cursor:not-allowed; }
.restart-note { color:var(--warn); font-family:var(--mono); font-size:9px; }
.pin-select { background:var(--bg); color:var(--text); border:1px solid var(--border); font-family:var(--mono); font-size:11px; padding:3px 6px; cursor:pointer; min-width:80px; }
.pin-select:disabled { color:var(--dim); border-color:var(--dim); cursor:not-allowed; }
.tap-flash { position:absolute; inset:0; pointer-events:none; opacity:0; transition:opacity 0.25s; }
.tap-flash.left { background:radial-gradient(circle at center, var(--tap-left) 0%, transparent 70%); }
.tap-flash.right { background:radial-gradient(circle at center, var(--tap-right) 0%, transparent 70%); }
.tap-flash.show { opacity:1; }
.viz-wrap { position:relative; }
/* ── OTA Firmware Update ── */
.ota-card { display:flex; flex-direction:column; gap:14px; }
.ota-notice { display:flex; gap:12px; align-items:flex-start; padding:10px 12px; background:color-mix(in srgb, var(--warn) 8%, var(--bg)); border-left:3px solid var(--warn); }
.ota-notice-icon { font-size:16px; color:var(--warn); flex-shrink:0; line-height:1.4; }
.ota-notice-title { font-family:var(--sans); font-size:11px; font-weight:700; color:var(--warn); letter-spacing:0.08em; text-transform:uppercase; margin-bottom:4px; }
.ota-notice-body { font-family:var(--mono); font-size:10px; color:var(--label); line-height:1.6; }
.ota-steps { font-family:var(--mono); font-size:10px; color:var(--label); line-height:1.9; margin:0; padding-left:18px; }
.ota-steps code { color:var(--text); }
.ota-steps strong { color:var(--text); }
.ota-steps em { color:var(--accent); font-style:normal; }
.ota-btn-row { display:grid; gap:8px; }
.ota-status-row { min-height:14px; }
.ota-status { font-family:var(--mono); font-size:10px; color:var(--label); }
.ota-status.ota-ok { color:var(--ok); }
.ota-status.ota-err { color:var(--accent2); }
.cmd-btn.ota-trigger::before { background:var(--accent); }
.cmd-btn.ota-trigger:hover { border-color:var(--accent); }