Compare commits

...

9 Commits

Author SHA1 Message Date
nikrozman 4aa369ca73 Double click support 2026-03-24 23:39:06 +01:00
nikrozman a7082c9022 Disable tap detection by default, simplify tracking 2026-03-24 23:30:06 +01:00
nikrozman 14e9c96f55 Improve sleep and add button waking 2026-03-24 23:29:34 +01:00
nikrozman 0fc38a5e1b Simplify tracking 2026-03-24 23:11:17 +01:00
nikrozman 8ab07adfc6 Add button support 2026-03-24 22:56:21 +01:00
nikrozman 2abc226652 Micro-adjust 3D model to support current PCB 2026-03-24 22:54:49 +01:00
nikrozman 5ab13a525a Redesign MCU and PCB mounting 2026-03-24 19:29:40 +01:00
nikrozman 502ea786b0 Better clip-in top mechanism 2026-03-23 19:22:46 +01:00
nikrozman 1486fe13f2 FIx battery quirks 2026-03-19 22:38:04 +01:00
9 changed files with 289 additions and 521 deletions
+156 -390
View File
@@ -1,430 +1,196 @@
""" """
IMU Pointer Enclosure — v6 IMU Pointer Enclosure — v11.7 (Slimmed Corners & Rounded USB-C)
============================
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 FreeCAD as App
import FreeCADGui as Gui import FreeCADGui as Gui
import Part import Part
import math
from FreeCAD import Base from FreeCAD import Base
doc = App.newDocument("pointer_v7") doc = App.newDocument("pointer_v11_7")
# ───────────────────────────────────────────────────────────────────────────── # ─── DIMENSIONS ───────────────────────────────────────────────────────────────
# DIMENSIONS L, W, H = 115.0, 36.0, 22.0
# ───────────────────────────────────────────────────────────────────────────── WALL = 3.5
L = 115.0 # length (X): front=0, back=L CR, CR_I = 8.0, 4.5
W = 36.0 # width (Y) TOL = 0.25
H = 22.0 # height (Z): bottom=0, top=H EDGE_FILLET = 3.0
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
# Taper: front of bottom shell is TAPER_RISE mm shorter than back. USBC_W, USBC_H, USBC_Z = 12.0, 7.0, 5.0
# Applied only to the BOTTOM shell (it's where the ergonomic taper lives). SPLIT_Z = USBC_Z + USBC_H + 2.5
TAPER_RISE = 0.0 # no taper — removed per user request
TAPER_LEN = 100.0 # unused but kept to avoid NameError
# Split plane # MICRO-DETENT Snap Logic
USBC_W = 11.0 TONGUE_H, TONGUE_T = 2.5, 2.0
USBC_H = 7.0 GROOVE_H, GROOVE_T = TONGUE_H + TOL, TONGUE_T + TOL
USBC_Z = 5.0 RIDGE_W = 12.0
SPLIT_Z = USBC_Z + USBC_H + 2.5 # = 14.5 mm RIDGE_H = 1.2
RIDGE_PROUD = 1.0 # Snap ridge protrusion
RIDGE_Z_OFF = (TONGUE_H - RIDGE_H) / 2.0
# Tongue/groove clip joint # ─── IMU BOARD (+1mm Spacing & Slim Corners) ──────────────────────────────────
TONGUE_H = 2.5 # how far tongue projects above SPLIT_Z PCB_T, BRD_L, BRD_W = 3.0, 22.6, 19.6
TONGUE_T = 1.2 # tongue wall thickness BRD_X, BRD_Y = WALL, (W - BRD_W) / 2.0
GROOVE_H = TONGUE_H + TOL PLATFORM_H, MIC_EXTRA = 1.5, 2.0
GROOVE_T = TONGUE_T + TOL MIC_PCB_T = 2.5 # Thicker PCB section (MEMS mic), rounded up from 2.2
BUMP_PROUD = 0.3 # Press-fit nub protrusion into board cavity
BUMP_R = 0.6 # Nub radius (half-sphere)
BRD_Z = WALL + PLATFORM_H
# Flex notch through tongue (for snap release) # ─── BUTTON & BATTERY ─────────────────────────────────────────────────────────
NOTCH_W = 8.0 BAT_L, BAT_W, BAT_H = 50.0, 12.0, 12.0
NOTCH_H = TONGUE_H + 0.5 BAT_X, BAT_Y = BRD_X + BRD_L + 8.0 + 5.0, (W - BAT_W) / 2.0
BAT_CLIP_Y = 8.0
BTN_X, BTN_CY, BTN_HOLE_R = 28.0, W / 2.0, 10.0
CAP_SHAFT_R, CAP_SHAFT_H = 9.6, WALL + 1.0 # +1mm taller shaft so cap sits flush
CAP_RIM_R, CAP_RIM_H = 12.0, 1.5
CAP_CAVITY_R, CAP_CAVITY_H = 5.2, 2.5 # Hollow cavity replaces nubbin — clears button dome
BTN_DOME_R, BTN_DOME_SAG = 14.0, 0.6
# ───────────────────────────────────────────────────────────────────────────── PCB_BOT_Z = SPLIT_Z + 1.5
# IMU BOARD POST_H = BRD_Z + PCB_T + MIC_EXTRA + 3.0 - 4.0 # Lowered 4mm for button PCB + button thickness
# ───────────────────────────────────────────────────────────────────────────── POST_OFFS_X, POST_OFFS_Y = 4.0, 11.0
PCB_T = 1.0 LH_R, LH_X, LH_Y_OFFS = 1.5, L - WALL - 3.0, 4.0
BRD_L = 21.0 BPCB_L, BPCB_W = 16.0, 16.0
BRD_W = 17.5
BRD_X = WALL
BRD_Y = (W - BRD_W) / 2.0
PLATFORM_H = 0.5
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
# ───────────────────────────────────────────────────────────────────────────── # ─── HELPERS ──────────────────────────────────────────────────────────────────
# 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
# ───────────────────────────────────────────────────────────────────────────── def box(lx, ly, lz, ox=0, oy=0, oz=0):
# 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
# Switch geometry (adjust to match your Omron)
SWITCH_BODY_H = 5.0
STEM_H = 2.5
GAP = 0.5
# 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
BPCB_L = 16.0
BPCB_W = 16.0
SHELF_WALL = 2.0
# ─────────────────────────────────────────────────────────────────────────────
# HELPERS
# ─────────────────────────────────────────────────────────────────────────────
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)) return Part.makeBox(lx, ly, lz, Base.Vector(ox, oy, oz))
def cyl(r, h, cx=0.0, cy=0.0, cz=0.0): def rbox(lx, ly, lz, ox=0, oy=0, oz=0, r=CR):
b = box(lx, ly, lz, ox, oy, oz)
try:
edges = [e for e in b.Edges if abs(e.Vertexes[0].X - e.Vertexes[1].X) < 1e-3 and abs(e.Vertexes[0].Y - e.Vertexes[1].Y) < 1e-3]
return b.makeFillet(r, edges) if edges else b
except: return b
def cyl(r, h, cx=0, cy=0, cz=0):
return Part.makeCylinder(r, h, Base.Vector(cx, cy, cz)) return Part.makeCylinder(r, h, Base.Vector(cx, cy, cz))
def rounded_slot(depth, sw, sh, ox, oy, oz): def fillet_horiz(solid, r, z_test):
"""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))
def fillet_vert(solid, r, min_len=4.0):
"""Fillet edges that are primarily vertical (parallel to Z)."""
try: try:
edges = [e for e in solid.Edges edges = [e for e in solid.Edges if abs(e.Vertexes[0].Z - e.Vertexes[1].Z) < 0.2 and abs((e.Vertexes[0].Z + e.Vertexes[1].Z)/2 - z_test) < 1.5]
if len(e.Vertexes) == 2 return solid.makeFillet(r, edges) if edges else solid
and abs(e.Vertexes[0].X - e.Vertexes[1].X) < 1e-3 except: return solid
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): def make_slim_corner(cx, cy, ix, iy):
plat_w = ARM_THICK + CLIP_TOL pw = 0.8 # Much slimmer wall thickness (was 1.5/1.6)
plat_x = cx if ix > 0 else cx - plat_w sl = 4.0 # Slightly shorter side length
plat_y = cy if iy > 0 else cy - plat_w h = PLATFORM_H + PCB_T + 0.5
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 x0, y0 = (cx if ix>0 else cx-sl), (cy if iy>0 else cy-pw)
ax_oy = cy - ARM_THICK - CLIP_TOL if iy > 0 else cy + CLIP_TOL w1 = box(sl, pw, h, x0, y0, WALL)
arm_x = box(ARM_LEN, ARM_THICK, ARM_H, ax_ox, ax_oy, WALL)
ay_oy = cy if iy > 0 else cy - ARM_LEN x1, y1 = (cx if ix>0 else cx-pw), (cy if iy>0 else cy-sl)
ay_ox = cx - ARM_THICK - CLIP_TOL if ix > 0 else cx + CLIP_TOL w2 = box(pw, sl, h, x1, y1, WALL)
arm_y = box(ARM_THICK, ARM_LEN, ARM_H, ay_ox, ay_oy, WALL)
cb_w = ARM_THICK + CLIP_TOL px, py = (cx if ix>0 else cx-sl), (cy if iy>0 else cy-sl)
cb_ox = cx - cb_w if ix > 0 else cx plat = box(sl, sl, PLATFORM_H, px, py, WALL)
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(w1).fuse(w2)
return plat.fuse(arm_x.fuse(arm_y).fuse(cb))
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
# BOTTOM SHELL (Z = 0 → SPLIT_Z, open on top) # CONSTRUCTION
# ═════════════════════════════════════════════════════════════════════════════ # ═════════════════════════════════════════════════════════════════════════════
# 1. Outer solid — built ONLY to SPLIT_Z height # BOTTOM SHELL
bot_outer = box(L, W, SPLIT_Z) bot_shell = fillet_horiz(rbox(L, W, SPLIT_Z + TONGUE_H), EDGE_FILLET, 0.0)
bot_outer = fillet_vert(bot_outer, CR, min_len=SPLIT_Z * 0.4) bot_shell = bot_shell.cut(rbox(L-WALL*2, W-WALL*2, SPLIT_Z, WALL, WALL, WALL, r=CR_I))
bot_shell = bot_shell.cut(rbox(L-TONGUE_T*2, W-TONGUE_T*2, TONGUE_H+2, TONGUE_T, TONGUE_T, SPLIT_Z, r=CR-TONGUE_T))
# Fillet the long horizontal edges the user holds. # Internal Fusions (Using Slim L-bracket style for MCU)
# These are the 4 edges running in X at Z≈0 and Z≈SPLIT_Z, on both long sides. 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)]:
# Same fillet applied to equivalent edges on the top shell later. bot_shell = bot_shell.fuse(make_slim_corner(cx, cy, ix, iy))
EDGE_FILLET = 2.5 # mm — soft and comfortable, visible but not decorative
try: # Press-fit nubs — half-sphere on each L-bracket's inner Y-facing wall (w1)
h_edges = [] bump_z = BRD_Z + 1.0 + BUMP_R # Bottom of nub sits 1mm above platform
for e in bot_outer.Edges: pw = 0.8; sl = 4.0 # Must match make_slim_corner
if len(e.Vertexes) != 2: for cx, cy, ix, iy in [(BRD_X, BRD_Y, 1, 1), (BRD_X+BRD_L, BRD_Y, -1, 1),
continue (BRD_X, BRD_Y+BRD_W, 1, -1), (BRD_X+BRD_L, BRD_Y+BRD_W, -1, -1)]:
v0, v1 = e.Vertexes[0], e.Vertexes[1] # w1 wall centre X: midpoint of the sl-long wall extending from corner
dx = abs(v0.X - v1.X) mid_x = cx + ix * sl / 2.0
dz = abs(v0.Z - v1.Z) # w1 inner face Y: the face that looks toward the board centre
dy = abs(v0.Y - v1.Y) face_y = cy if iy > 0 else cy - pw # wall origin Y
# Long edge in X, horizontal, on a long side face — inner_y = face_y + pw if iy > 0 else face_y # the side facing inward
# but ONLY at Z≈0 (bottom face). Exclude Z≈SPLIT_Z (the join edge). # iy>0 → bump faces +Y (inward), iy<0 → bump faces -Y (inward)
z_mid = (v0.Z + v1.Z) / 2.0 # Actually: iy>0 means corner is at low-Y side, wall inner face = face_y+pw, bump goes +Y
if dx > L * 0.5 and dz < 0.5 and dy < 0.5 and z_mid < 1.0: # iy<0 means corner is at high-Y side, wall inner face = face_y, bump goes -Y
h_edges.append(e) sph = Part.makeSphere(BUMP_R, Base.Vector(mid_x, inner_y, bump_z))
if h_edges: cs = BUMP_R + 0.5
bot_outer = bot_outer.makeFillet(EDGE_FILLET, h_edges) # Clip: keep only the half protruding inward (toward board centre)
print(f"Bottom shell: filleted {len(h_edges)} horizontal edge(s) R={EDGE_FILLET}") if iy > 0:
clip = box(cs*2, cs, cs*2, mid_x - cs, inner_y, bump_z - cs)
else: else:
print("Bottom shell: no horizontal edges found — skipped") clip = box(cs*2, cs, cs*2, mid_x - cs, inner_y - cs, bump_z - cs)
except Exception as exc: half_sph = sph.common(clip)
print(f"Bottom shell horizontal fillet skipped: {exc}") bot_shell = bot_shell.fuse(half_sph)
# No taper wedge — removed per user request POST_R = 1.75
POST_TAPER_EXTRA = 0.3 # Extra radius at base
POST_TAPER_H = 6.0 # Height over which the taper blends to nominal radius
BACK_POST_SHIFT = POST_R # Shift back posts by half a post diameter
for ox in [-POST_OFFS_X, POST_OFFS_X]:
for oy in [-POST_OFFS_Y, POST_OFFS_Y]:
px = BTN_X + ox + (BACK_POST_SHIFT if ox > 0 else 0)
py = BTN_CY + oy
post = cyl(POST_R, POST_H, px, py, WALL)
# Tapered cone base: wider at bottom, blends to post radius at POST_TAPER_H
taper = Part.makeCone(POST_R + POST_TAPER_EXTRA, POST_R, POST_TAPER_H,
Base.Vector(px, py, WALL))
post = post.fuse(taper)
post = post.cut(cyl(0.5, POST_H + 1, px, py, WALL))
bot_shell = bot_shell.fuse(post)
# 3. Inner cavity — floor at WALL, ceiling at SPLIT_Z (open top, no ceiling) # Rounded USB-C Cut (Pill Shape)
bot_cav_lx = L - WALL * 2 usbc_r = USBC_H / 2.0
bot_cav_ly = W - WALL * 2 usbc_box = box(WALL*4, USBC_W - 2*usbc_r, USBC_H, -1, W/2 - USBC_W/2 + usbc_r, USBC_Z)
bot_cav_lz = SPLIT_Z - WALL # floor(WALL) → SPLIT_Z usbc_cyl1 = Part.makeCylinder(usbc_r, WALL*4, Base.Vector(-1, W/2 - USBC_W/2 + usbc_r, USBC_Z + usbc_r), Base.Vector(1, 0, 0))
bot_inner = box(bot_cav_lx, bot_cav_ly, bot_cav_lz, WALL, WALL, WALL) usbc_cyl2 = Part.makeCylinder(usbc_r, WALL*4, Base.Vector(-1, W/2 + USBC_W/2 - usbc_r, USBC_Z + usbc_r), Base.Vector(1, 0, 0))
bot_shell = bot_outer.cut(bot_inner) usbc_rounded = usbc_box.fuse(usbc_cyl1).fuse(usbc_cyl2)
bot_shell = bot_shell.cut(usbc_rounded)
# 4. Tongue (projects UP from SPLIT_Z, inner perimeter frame) # Battery bay + retaining tabs
# 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)
# 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)
# 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))
# 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))
# 7. Battery bay
bot_shell = bot_shell.cut(box(BAT_L, BAT_W, 3.0, BAT_X, BAT_Y, WALL)) 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 bat_clip_cy = 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 - 2.0, bat_clip_cy, WALL))
bot_shell = bot_shell.fuse(box(2.0, BAT_CLIP_Y, BAT_H * 0.55, BAT_X + BAT_L, cy0, WALL)) bot_shell = bot_shell.fuse(box(2.0, BAT_CLIP_Y, BAT_H * 0.55, BAT_X + BAT_L, bat_clip_cy, WALL))
# ═════════════════════════════════════════════════════════════════════════════ # MICRO-DETENT RIDGES: Buried deep, barely protruding
# TOP SHELL (Z = SPLIT_Z → H, open on bottom, closed ceiling at H) rx0, rz0 = L/2 - RIDGE_W/2, SPLIT_Z + RIDGE_Z_OFF
# ═════════════════════════════════════════════════════════════════════════════ ridge_bury = 1.5
top_h = H - SPLIT_Z # = 7.5 mm ridge_total_t = ridge_bury + RIDGE_PROUD
bot_shell = bot_shell.fuse(box(RIDGE_W, ridge_total_t, RIDGE_H, rx0, TONGUE_T - ridge_bury, rz0))
bot_shell = bot_shell.fuse(box(RIDGE_W, ridge_total_t, RIDGE_H, rx0, W - TONGUE_T - RIDGE_PROUD, rz0))
# 1. Outer solid spans SPLIT_Z → H # TOP SHELL
top_outer = box(L, W, top_h, 0, 0, SPLIT_Z) top_shell = fillet_horiz(rbox(L, W, H-SPLIT_Z, 0, 0, SPLIT_Z), EDGE_FILLET, H)
top_outer = fillet_vert(top_outer, CR, min_len=top_h * 0.4) top_shell = top_shell.cut(rbox(L-WALL*2, W-WALL*2, H-SPLIT_Z-WALL, WALL, WALL, SPLIT_Z, r=CR_I))
# Fillet the long horizontal edges of the top shell — # Groove and Matching Recesses
# the top edges (Z≈H) are the ones selected in blue in the user's screenshot. g_band = rbox(L, W, GROOVE_H, 0, 0, SPLIT_Z, r=CR).cut(rbox(L-GROOVE_T*2, W-GROOVE_T*2, GROOVE_H+2, GROOVE_T, GROOVE_T, SPLIT_Z-1, r=CR-GROOVE_T))
try: top_shell = top_shell.cut(g_band)
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}")
# 2. Inner cavity: side walls WALL thick, CEILING at H-WALL (WALL-thick roof), # Recesses in groove wall — bottom ridges click into these
# FLOOR open (starts at SPLIT_Z — nothing blocks the bottom opening). rec_w = RIDGE_W + TOL*2
# Cavity box: X from WALL→L-WALL, Y from WALL→W-WALL, Z from SPLIT_Z→H-WALL rec_d = RIDGE_PROUD + TOL # Slightly deeper than ridge protrusion
top_cav_lx = L - WALL * 2 top_shell = top_shell.cut(box(rec_w, rec_d, RIDGE_H+TOL, L/2-rec_w/2, GROOVE_T, rz0-TOL/2))
top_cav_ly = W - WALL * 2 top_shell = top_shell.cut(box(rec_w, rec_d, RIDGE_H+TOL, L/2-rec_w/2, W-GROOVE_T-rec_d, rz0-TOL/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)
# 3. Groove at the bottom of the top shell, starting at SPLIT_Z # Button & Cap
# The groove is a frame-shaped recess cut into the inner face of the walls. top_shell = top_shell.cut(cyl(BTN_HOLE_R, H, BTN_X, BTN_CY, SPLIT_Z))
# It goes from Z=SPLIT_Z up to Z=SPLIT_Z+GROOVE_H. top_shell = top_shell.cut(Part.makeSphere(BTN_DOME_R, Base.Vector(BTN_X, BTN_CY, H - WALL - BTN_DOME_R + BTN_DOME_SAG)))
# Width = GROOVE_T (slightly wider than tongue). cap = cyl(CAP_SHAFT_R, CAP_SHAFT_H).fuse(cyl(CAP_RIM_R, CAP_RIM_H, 0, 0, -CAP_RIM_H))
g_slab = box(top_cav_lx, top_cav_ly, GROOVE_H, # Hollow cavity in bottom of shaft — button dome nests inside instead of a protruding nubbin
WALL, WALL, SPLIT_Z) cap = cap.cut(cyl(CAP_CAVITY_R, CAP_CAVITY_H, 0, 0, -CAP_RIM_H))
g_cut = box(top_cav_lx - GROOVE_T*2, top_cav_ly - GROOVE_T*2, GROOVE_H + 1, cap_placed = cap.copy(); cap_placed.translate(Base.Vector(BTN_X, BTN_CY, H - CAP_SHAFT_H))
WALL + GROOVE_T, WALL + GROOVE_T, SPLIT_Z - 0.5)
groove = g_slab.cut(g_cut)
top_shell = top_shell.cut(groove)
# 4. Button aperture — brute-force: run cylinder from Z=SPLIT_Z to Z=H+2. # ─── REGISTER ────────────────────────────────────────────────────────────────
# It will punch through the ceiling regardless of any topology. for name, shape, color in [("Shell_Bottom", bot_shell, (0.15, 0.15, 0.18)),
top_shell = top_shell.cut( ("Shell_Top", top_shell, (0.25, 0.25, 0.32)),
cyl(BTN_HOLE_R, H - SPLIT_Z + 2, BTN_X, BTN_CY, SPLIT_Z)) ("Button_Cap", cap_placed, (0.7, 0.7, 0.7))]:
obj = doc.addObject("Part::Feature", name)
# 5. Button PCB shelf frame obj.Shape = shape
shelf_ox = BTN_X - BPCB_L / 2.0 obj.ViewObject.ShapeColor = color
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)
# 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)
# ═════════════════════════════════════════════════════════════════════════════
# 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))
# ═════════════════════════════════════════════════════════════════════════════
# 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
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() 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)
+3 -2
View File
@@ -36,14 +36,15 @@ void updateBattery() {
ChargeStatus status = chg ? (pct >= 99 ? CHGSTAT_FULL : CHGSTAT_CHARGING) : CHGSTAT_DISCHARGING; ChargeStatus status = chg ? (pct >= 99 ? CHGSTAT_FULL : CHGSTAT_CHARGING) : CHGSTAT_DISCHARGING;
// Only write BLE Battery Service when connected - blebas.write() blocks on the // Only write BLE Battery Service when connected - blebas.write() blocks on the
// SoftDevice ATT layer and causes 30-40ms loop stalls when called during advertising. // SoftDevice ATT layer and causes 30-40ms loop stalls when called during advertising.
if (Bluefruit.connected()) blebas.write(pct); if (Bluefruit.connected()) blebas.notify(pct);
lastChargeStatus = status; lastChargeStatus = status;
#ifdef FEATURE_TELEMETRY #ifdef FEATURE_TELEMETRY
telem.chargeStatus = (uint8_t)status; telem.chargeStatus = (uint8_t)status;
#endif #endif
const char* st[] = {"discharging","charging","full"}; const char* st[] = {"discharging","charging","full"};
Serial.print("[BATT] "); Serial.print(v,2); Serial.print("V "); Serial.print("[BATT] "); Serial.print(v,2); Serial.print("V ");
Serial.print(pct); Serial.print("% "); Serial.println(st[status]); Serial.print(pct); Serial.print("% "); Serial.print(st[status]);
Serial.print(" (PIN_CHG="); Serial.print(digitalRead(PIN_CHG)); Serial.println(")");
// Critical battery alert - only blink when not connected to avoid blocking BLE scheduler. // Critical battery alert - only blink when not connected to avoid blocking BLE scheduler.
// 6 × 160ms = 960ms hard block; skip during active connection. // 6 × 160ms = 960ms hard block; skip during active connection.
if (status == CHGSTAT_DISCHARGING && v < BATT_CRITICAL && !Bluefruit.connected()) if (status == CHGSTAT_DISCHARGING && v < BATT_CRITICAL && !Bluefruit.connected())
+51 -7
View File
@@ -4,8 +4,18 @@
#include <bluefruit.h> #include <bluefruit.h>
extern BLEHidAdafruit blehid; extern BLEHidAdafruit blehid;
extern Config cfg;
static uint8_t physBtnMask = 0; // bitmask of currently-pressed physical buttons static uint8_t physBtnMask = 0;
static uint8_t rawMaskPrev = 0;
static unsigned long debounceMs = 0;
static const unsigned long DEBOUNCE_MS = 20;
// Double-press detection for left button
static const unsigned long DOUBLE_PRESS_MS = 400; // max gap between two releases
static const unsigned long KEY_HOLD_MS = 60; // how long to hold the key down
static unsigned long lastLeftReleaseMs = 0;
static unsigned long keyDownUntil = 0;
// Setup // Setup
void setupPhysicalButtons() { void setupPhysicalButtons() {
@@ -36,16 +46,50 @@ void setupPhysicalButtons() {
void processPhysicalButtons() { void processPhysicalButtons() {
if (!Bluefruit.connected()) return; if (!Bluefruit.connected()) return;
uint8_t newMask = 0; unsigned long now = millis();
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) { // Release held key combo after KEY_HOLD_MS
if (keyDownUntil && now >= keyDownUntil) {
uint8_t noKeys[6] = {};
blehid.keyboardReport(0, noKeys);
keyDownUntil = 0;
Serial.println("[BTN] key release");
}
uint8_t rawMask = 0;
if (BTN_LEFT_PIN != BTN_PIN_NONE && digitalRead(BTN_LEFT_PIN) == LOW) rawMask |= MOUSE_BUTTON_LEFT;
if (BTN_RIGHT_PIN != BTN_PIN_NONE && digitalRead(BTN_RIGHT_PIN) == LOW) rawMask |= MOUSE_BUTTON_RIGHT;
if (BTN_MIDDLE_PIN != BTN_PIN_NONE && digitalRead(BTN_MIDDLE_PIN) == LOW) rawMask |= MOUSE_BUTTON_MIDDLE;
if (rawMask != rawMaskPrev) { rawMaskPrev = rawMask; debounceMs = now; }
if (rawMask != physBtnMask && (now - debounceMs >= DEBOUNCE_MS)) {
uint8_t newMask = rawMask;
uint8_t pressed = newMask & ~physBtnMask;
uint8_t released = physBtnMask & ~newMask;
physBtnMask = newMask; physBtnMask = newMask;
if (physBtnMask) blehid.mouseButtonPress(physBtnMask); if (physBtnMask) blehid.mouseButtonPress(physBtnMask);
else blehid.mouseButtonRelease(); else blehid.mouseButtonRelease();
Serial.print("[BTN] mask=0x"); Serial.println(physBtnMask, HEX); if (pressed & MOUSE_BUTTON_LEFT) Serial.println("[BTN] L press");
if (pressed & MOUSE_BUTTON_RIGHT) Serial.println("[BTN] R press");
if (pressed & MOUSE_BUTTON_MIDDLE) Serial.println("[BTN] M press");
if (released & MOUSE_BUTTON_LEFT) {
unsigned long gap = lastLeftReleaseMs ? (now - lastLeftReleaseMs) : 0;
Serial.print("[BTN] L release - gap="); Serial.print(gap);
Serial.print("ms (max="); Serial.print(DOUBLE_PRESS_MS); Serial.println("ms)");
// Double-press detection: two short presses → fire key combo
if (lastLeftReleaseMs && (gap <= DOUBLE_PRESS_MS)) {
uint8_t keys[6] = {cfg.tapKey, 0, 0, 0, 0, 0};
blehid.keyboardReport(cfg.tapMod, keys);
keyDownUntil = now + KEY_HOLD_MS;
lastLeftReleaseMs = 0;
Serial.print("[BTN] Double-press → key 0x"); Serial.print(cfg.tapKey, HEX);
Serial.print(" mod 0x"); Serial.println(cfg.tapMod, HEX);
} else {
lastLeftReleaseMs = now;
}
}
if (released & MOUSE_BUTTON_RIGHT) Serial.println("[BTN] R release");
if (released & MOUSE_BUTTON_MIDDLE) Serial.println("[BTN] M release");
} }
} }
+4 -7
View File
@@ -5,7 +5,7 @@
#define FEATURE_CONFIG_SERVICE #define FEATURE_CONFIG_SERVICE
#define FEATURE_TELEMETRY #define FEATURE_TELEMETRY
#define FEATURE_IMU_STREAM #define FEATURE_IMU_STREAM
#define FEATURE_TAP_DETECTION // #define FEATURE_TAP_DETECTION
#define FEATURE_TEMP_COMPENSATION #define FEATURE_TEMP_COMPENSATION
#define FEATURE_AUTO_RECAL #define FEATURE_AUTO_RECAL
#define FEATURE_BATTERY_MONITOR #define FEATURE_BATTERY_MONITOR
@@ -60,7 +60,7 @@
// Physical button pin assignments (hardcoded - set to 0xFF to disable a button) // Physical button pin assignments (hardcoded - set to 0xFF to disable a button)
// Valid pin numbers: 0-10 (Arduino D0-D10 on XIAO nRF52840 Sense) // Valid pin numbers: 0-10 (Arduino D0-D10 on XIAO nRF52840 Sense)
#define BTN_PIN_NONE 0xFF #define BTN_PIN_NONE 0xFF
#define BTN_LEFT_PIN BTN_PIN_NONE // e.g. 0 for D0 #define BTN_LEFT_PIN 1 // D1, active-low to GND
#define BTN_RIGHT_PIN BTN_PIN_NONE // e.g. 1 for D1 #define BTN_RIGHT_PIN BTN_PIN_NONE // e.g. 1 for D1
#define BTN_MIDDLE_PIN BTN_PIN_NONE // e.g. 2 for D2 #define BTN_MIDDLE_PIN BTN_PIN_NONE // e.g. 2 for D2
@@ -158,8 +158,7 @@ struct __attribute__((packed)) ImuPacket {
static_assert(sizeof(ImuPacket) == 14, "ImuPacket must be 14 bytes"); static_assert(sizeof(ImuPacket) == 14, "ImuPacket must be 14 bytes");
#endif #endif
// Tuning constants // Tuning constants
extern const float ALPHA;
extern const int LOOP_RATE_MS; extern const int LOOP_RATE_MS;
extern const int BIAS_SAMPLES; extern const int BIAS_SAMPLES;
extern const int IDLE_FRAMES; extern const int IDLE_FRAMES;
@@ -184,10 +183,8 @@ extern const float BATT_CRITICAL;
extern const unsigned long AUTO_RECAL_MS; extern const unsigned long AUTO_RECAL_MS;
#endif #endif
// Global state // Global state
extern float angleX, angleY;
extern float accumX, accumY; extern float accumX, accumY;
extern float gravX, gravY, gravZ;
extern float biasGX, biasGY, biasGZ; extern float biasGX, biasGY, biasGZ;
extern float calTempC; extern float calTempC;
extern float cachedTempC; extern float cachedTempC;
+1 -3
View File
@@ -34,9 +34,7 @@ void calibrateGyroBias() {
biasGY = (float)(sy/BIAS_SAMPLES); biasGY = (float)(sy/BIAS_SAMPLES);
biasGZ = (float)(sz/BIAS_SAMPLES); biasGZ = (float)(sz/BIAS_SAMPLES);
calTempC = readIMUTemp(); calTempC = readIMUTemp();
angleX = angleY = accumX = accumY = 0.0f; accumX = accumY = 0.0f;
// Seed gravity estimate from current accel so projection is correct immediately
gravX = imu.readFloatAccelX(); gravY = imu.readFloatAccelY(); gravZ = imu.readFloatAccelZ();
#ifdef FEATURE_TELEMETRY #ifdef FEATURE_TELEMETRY
statRecalCount++; statRecalCount++;
+33 -81
View File
@@ -17,7 +17,6 @@
#include "imu.h" #include "imu.h"
#include "ble_config.h" #include "ble_config.h"
#include "battery.h" #include "battery.h"
#include "tap.h"
#include "buttons.h" #include "buttons.h"
#include <bluefruit.h> #include <bluefruit.h>
#include <Adafruit_LittleFS.h> #include <Adafruit_LittleFS.h>
@@ -46,7 +45,7 @@ File cfgFile(InternalFS);
Config cfg; Config cfg;
const Config CFG_DEFAULTS = { const Config CFG_DEFAULTS = {
CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW, CONFIG_MAGIC, 600.0f, 0.060f, 0.08f, CURVE_LINEAR, 0x00, CHARGE_SLOW,
/*tapThreshold=*/12, /*tapAction=*/TAP_ACTION_LEFT, /*tapKey=*/0, /*tapMod=*/0, /*tapThreshold=*/12, /*tapAction=*/TAP_ACTION_KEY, /*tapKey=*/0x04, /*tapMod=*/0x03, // Ctrl+Shift+A
/*jerkThreshold=*/2000.0f, /*tapFreezeEnabled=*/1, /*featureFlags=*/FLAG_ALL_DEFAULT /*jerkThreshold=*/2000.0f, /*tapFreezeEnabled=*/1, /*featureFlags=*/FLAG_ALL_DEFAULT
}; };
@@ -55,11 +54,9 @@ const Config CFG_DEFAULTS = {
TelemetryPacket telem = {}; TelemetryPacket telem = {};
#endif #endif
// Tuning constants // Tuning constants
const float ALPHA = 0.96f;
const int LOOP_RATE_MS = 10; const int LOOP_RATE_MS = 10;
const float SMOOTH_LOW_RPS = 0.15f; // below this → heavy EMA smoothing (~8°/s) const float SMOOTH_ALPHA = 0.65f; // single-pole low-pass for cursor smoothing
const float SMOOTH_HIGH_RPS = 0.50f; // above this → no smoothing (~29°/s)
const int BIAS_SAMPLES = 200; const int BIAS_SAMPLES = 200;
const int IDLE_FRAMES = 150; const int IDLE_FRAMES = 150;
const unsigned long BATT_REPORT_MS = 20000; const unsigned long BATT_REPORT_MS = 20000;
@@ -83,11 +80,8 @@ const float BATT_CRITICAL = 3.10f;
const unsigned long AUTO_RECAL_MS = 5UL * 60UL * 1000UL; const unsigned long AUTO_RECAL_MS = 5UL * 60UL * 1000UL;
#endif #endif
// Global state definitions // Global state definitions
float angleX = 0, angleY = 0;
float accumX = 0, accumY = 0; float accumX = 0, accumY = 0;
// Low-pass filtered gravity estimate in device frame (for roll-independent axis projection)
float gravX = 0, gravY = 0, gravZ = 1.0f;
float biasGX = 0, biasGY = 0, biasGZ = 0; float biasGX = 0, biasGY = 0, biasGZ = 0;
float calTempC = 25.0f; float calTempC = 25.0f;
float cachedTempC = 25.0f; float cachedTempC = 25.0f;
@@ -121,10 +115,6 @@ bool pendingReset = false;
bool pendingOTA = false; bool pendingOTA = false;
#endif #endif
// Jerk-based shock detection - freeze cursor during tap impacts, doesn't work well yet!
unsigned long shockFreezeUntil = 0;
float prevAx = 0, prevAy = 0, prevAz = 0; // previous frame's accel for Δa
const unsigned long SHOCK_FREEZE_MS = 80; // hold freeze after last spike
ChargeStatus lastChargeStatus = CHGSTAT_DISCHARGING; ChargeStatus lastChargeStatus = CHGSTAT_DISCHARGING;
@@ -225,8 +215,6 @@ void setup() {
#endif #endif
calibrateGyroBias(); calibrateGyroBias();
// Seed previous-accel for jerk detection so first frame doesn't spike
prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ();
sleepManagerInit(); sleepManagerInit();
@@ -297,17 +285,19 @@ void loop() {
} }
#endif #endif
// Serial commands: 'c' = calibrate, 'r' = factory reset // Serial commands: 'c' = calibrate, 'r' = factory reset, 'd' = axis diagnostic
static unsigned long diagUntil = 0;
while (Serial.available()) { while (Serial.available()) {
char cmd = Serial.read(); char cmd = Serial.read();
if (cmd == 'c') { Serial.println("[SERIAL] Calibrate"); pendingCal = true; } if (cmd == 'c') { Serial.println("[SERIAL] Calibrate"); pendingCal = true; }
if (cmd == 'r') { Serial.println("[SERIAL] Reset"); pendingReset = true; } if (cmd == 'r') { Serial.println("[SERIAL] Reset"); pendingReset = true; }
if (cmd == 'd') { Serial.println("[DIAG] Printing raw gyro for 10s — pan, nod, roll one at a time"); diagUntil = now + 10000; }
#ifdef FEATURE_OTA #ifdef FEATURE_OTA
if (cmd == 'o') { Serial.println("[SERIAL] OTA DFU"); pendingOTA = true; } if (cmd == 'o') { Serial.println("[SERIAL] OTA DFU"); pendingOTA = true; }
#endif #endif
} }
if (pendingCal) { pendingCal = false; calibrateGyroBias(); prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); } if (pendingCal) { pendingCal = false; calibrateGyroBias(); }
if (pendingReset) { pendingReset = false; factoryReset(); } if (pendingReset) { pendingReset = false; factoryReset(); }
#ifdef FEATURE_OTA #ifdef FEATURE_OTA
if (pendingOTA) { if (pendingOTA) {
@@ -371,62 +361,32 @@ void loop() {
if (cfg.featureFlags & FLAG_TEMP_COMP_ENABLED) if (cfg.featureFlags & FLAG_TEMP_COMP_ENABLED)
correction = TEMP_COMP_COEFF_DPS_C * (cachedTempC - calTempC); correction = TEMP_COMP_COEFF_DPS_C * (cachedTempC - calTempC);
#endif #endif
float gx = (imu.readFloatGyroX() - biasGX - correction) * (PI/180.0f); float gx = (imu.readFloatGyroX() - biasGX) * (PI/180.0f); // roll (unused for cursor)
float gy = (imu.readFloatGyroY() - biasGY - correction) * (PI/180.0f); float gy = (imu.readFloatGyroY() - biasGY - correction) * (PI/180.0f); // pitch → cursor Y
float gz = (imu.readFloatGyroZ() - biasGZ - correction) * (PI/180.0f); float gz = (imu.readFloatGyroZ() - biasGZ - correction) * (PI/180.0f); // yaw → cursor X
float ax = imu.readFloatAccelX(); // Axis diagnostic — send 'd' over serial to enable
float ay = imu.readFloatAccelY(); if (diagUntil && now < diagUntil) {
float az = imu.readFloatAccelZ(); static unsigned long lastDiagPrint = 0;
if (now - lastDiagPrint >= 100) { lastDiagPrint = now;
Serial.print("[DIAG] gx="); Serial.print(gx,3);
Serial.print(" gy="); Serial.print(gy,3);
Serial.print(" gz="); Serial.println(gz,3);
}
} else if (diagUntil) { diagUntil = 0; Serial.println("[DIAG] Done"); }
// Jerk-based shock detection - freeze cursor during tap impacts, doesn't work well yet // Direct axis mapping (empirically verified via diagnostic)
float jx = (ax - prevAx) / dt, jy = (ay - prevAy) / dt, jz = (az - prevAz) / dt; float yawRate = gz; // gyroZ = pan left/right → cursor X
float jerkSq = jx*jx + jy*jy + jz*jz; float pitchRate = gy; // gyroY = nod up/down → cursor Y
prevAx = ax; prevAy = ay; prevAz = az;
bool shocked = cfg.tapFreezeEnabled && ((jerkSq > cfg.jerkThreshold) || (now < shockFreezeUntil));
if (cfg.tapFreezeEnabled && jerkSq > cfg.jerkThreshold) shockFreezeUntil = now + SHOCK_FREEZE_MS;
// Complementary filter // Dead zone (equal for both axes)
if (shocked) { float fYaw = (fabsf(yawRate) > cfg.deadZone) ? yawRate : 0.0f;
angleX += gx * dt; float fPitch = (fabsf(pitchRate) > cfg.deadZone) ? pitchRate : 0.0f;
angleY += gz * dt;
} else {
angleX = ALPHA*(angleX + gx*dt) + (1.0f - ALPHA)*atan2f(ax, sqrtf(ay*ay + az*az));
angleY = ALPHA*(angleY + gz*dt) + (1.0f - ALPHA)*atan2f(ay, sqrtf(ax*ax + az*az));
}
// Gravity-based axis decomposition
const float GRAV_LP = 0.05f;
if (!shocked) {
gravX += GRAV_LP * (ax - gravX);
gravY += GRAV_LP * (ay - gravY);
gravZ += GRAV_LP * (az - gravZ);
}
float gN = sqrtf(gravX*gravX + gravY*gravY + gravZ*gravZ);
if (gN < 0.3f) gN = 1.0f;
float gnx = gravX/gN, gny = gravY/gN, gnz = gravZ/gN;
float ry = -gnz, rz = gny;
float rN = sqrtf(ry*ry + rz*rz);
if (rN < 0.01f) { ry = -1.0f; rz = 0.0f; rN = 1.0f; }
ry /= rN; rz /= rN;
// Yaw (cursor X) = angular velocity component around gravity (vertical)
// Pitch (cursor Y) = angular velocity component around screen-right
float yawRate = gx*gnx + gy*gny + gz*gnz;
float pitchRate = -(gy*ry + gz*rz);
// Projected rates amplify residual gyro bias (especially GY drift on pitch axis).
float fYaw = (fabsf(yawRate) > cfg.deadZone) ? yawRate : 0.0f;
float fPitch = (fabsf(pitchRate) > cfg.deadZone * 3.0f) ? pitchRate : 0.0f;
#ifdef DEBUG #ifdef DEBUG
{ static unsigned long lastDiag = 0; { static unsigned long lastDiag = 0;
if (now - lastDiag >= 500) { lastDiag = now; if (now - lastDiag >= 500) { lastDiag = now;
Serial.print("[PROJ] grav="); Serial.print(gnx,2); Serial.print(","); Serial.print(gny,2); Serial.print(","); Serial.print(gnz,2); Serial.print("[IMU] gyro="); Serial.print(gx,2); Serial.print(","); Serial.print(gy,2); Serial.print(","); Serial.print(gz,2);
Serial.print(" R="); Serial.print(ry,2); Serial.print(","); Serial.print(rz,2);
Serial.print(" gyro="); Serial.print(gx,2); Serial.print(","); Serial.print(gy,2); Serial.print(","); Serial.print(gz,2);
Serial.print(" yaw="); Serial.print(yawRate,3); Serial.print(" pitch="); Serial.println(pitchRate,3); Serial.print(" yaw="); Serial.print(yawRate,3); Serial.print(" pitch="); Serial.println(pitchRate,3);
} }
} }
@@ -440,7 +400,7 @@ void loop() {
#ifdef FEATURE_AUTO_RECAL #ifdef FEATURE_AUTO_RECAL
if ((cfg.featureFlags & FLAG_AUTO_RECAL_ENABLED) && idle && idleStartMs != 0 && (now - idleStartMs >= AUTO_RECAL_MS)) { if ((cfg.featureFlags & FLAG_AUTO_RECAL_ENABLED) && idle && idleStartMs != 0 && (now - idleStartMs >= AUTO_RECAL_MS)) {
Serial.println("[AUTO-CAL] Long idle - recalibrating..."); Serial.println("[AUTO-CAL] Long idle - recalibrating...");
idleStartMs = 0; calibrateGyroBias(); prevAx = imu.readFloatAccelX(); prevAy = imu.readFloatAccelY(); prevAz = imu.readFloatAccelZ(); return; idleStartMs = 0; calibrateGyroBias(); return;
} }
#endif #endif
@@ -449,12 +409,7 @@ void loop() {
static float smoothX = 0.0f, smoothY = 0.0f; static float smoothX = 0.0f, smoothY = 0.0f;
if (shocked) { if (idle) {
// 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; smoothX = smoothY = 0.0f;
accumX = accumY = 0.0f; accumX = accumY = 0.0f;
flags |= 0x01; flags |= 0x01;
@@ -463,13 +418,9 @@ void loop() {
float rawY = applyAcceleration(applyCurve(-fPitch * cfg.sensitivity * dt)); float rawY = applyAcceleration(applyCurve(-fPitch * cfg.sensitivity * dt));
if (cfg.axisFlip & 0x01) rawX = -rawX; if (cfg.axisFlip & 0x01) rawX = -rawX;
if (cfg.axisFlip & 0x02) rawY = -rawY; if (cfg.axisFlip & 0x02) rawY = -rawY;
// Tiered velocity smoothing: heavy EMA when nearly still, none when fast. // Single-pole low-pass smoothing
// Thresholds are in rad/s (angular rate), independent of sensitivity setting. smoothX = smoothX * (1.0f - SMOOTH_ALPHA) + rawX * SMOOTH_ALPHA;
float speed = sqrtf(fYaw*fYaw + fPitch*fPitch); smoothY = smoothY * (1.0f - SMOOTH_ALPHA) + rawY * SMOOTH_ALPHA;
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; accumX += smoothX; accumY += smoothY;
moveX = (int8_t)constrain((int)accumX, -127, 127); moveX = (int8_t)constrain((int)accumX, -127, 127);
moveY = (int8_t)constrain((int)accumY, -127, 127); moveY = (int8_t)constrain((int)accumY, -127, 127);
@@ -485,6 +436,7 @@ void loop() {
if (now < streamBackoffUntil) { if (now < streamBackoffUntil) {
// Backing off - host TX buffer congested, skip to avoid 100ms block // Backing off - host TX buffer congested, skip to avoid 100ms block
} else { } else {
float ax = imu.readFloatAccelX(), ay = imu.readFloatAccelY(), az = imu.readFloatAccelZ();
ImuPacket pkt; ImuPacket pkt;
pkt.gyroX_mDPS = (int16_t)constrain(gx*(180.f/PI)*1000.f, -32000, 32000); pkt.gyroX_mDPS = (int16_t)constrain(gx*(180.f/PI)*1000.f, -32000, 32000);
pkt.gyroZ_mDPS = (int16_t)constrain(gz*(180.f/PI)*1000.f, -32000, 32000); pkt.gyroZ_mDPS = (int16_t)constrain(gz*(180.f/PI)*1000.f, -32000, 32000);
+33 -20
View File
@@ -61,11 +61,16 @@ static void lsmWrite(uint8_t reg, uint8_t val) {
Wire1.endTransmission(); Wire1.endTransmission();
} }
// ISR // ISR
static void imuInt1ISR() { static void imuInt1ISR() {
imuWakeFlag = true; imuWakeFlag = true;
} }
static volatile bool btnWakeFlag = false;
static void btnWakeISR() {
btnWakeFlag = true;
}
// Arm wakeup interrupt // Arm wakeup interrupt
static void armWakeupInterrupt() { static void armWakeupInterrupt() {
lsmWrite(SLP_WAKE_UP_DUR, (uint8_t)((SLEEP_WAKEUP_DUR & 0x03) << 4)); lsmWrite(SLP_WAKE_UP_DUR, (uint8_t)((SLEEP_WAKEUP_DUR & 0x03) << 4));
@@ -127,6 +132,15 @@ static void enterImuLP() {
armWakeupInterrupt(); armWakeupInterrupt();
// Arm button wake interrupt
#if BTN_LEFT_PIN != BTN_PIN_NONE
btnWakeFlag = false;
attachInterrupt(digitalPinToInterrupt(BTN_LEFT_PIN), btnWakeISR, FALLING);
#endif
// Turn off all LEDs for sleep
digitalWrite(LED_RED, HIGH); digitalWrite(LED_GREEN, HIGH); digitalWrite(LED_BLUE, HIGH);
lpEnteredMs = millis(); lpEnteredMs = millis();
sleepStage = SLEEP_IMU_LP; sleepStage = SLEEP_IMU_LP;
Serial.print("[SLEEP] IMU LP entered - idle for "); Serial.print("[SLEEP] IMU LP entered - idle for ");
@@ -153,9 +167,9 @@ static void enterDeepSleep() {
Serial.println("[SLEEP] Deep sleep - WFE on INT1"); Serial.println("[SLEEP] Deep sleep - WFE on INT1");
Serial.flush(); Serial.flush();
digitalWrite(LED_RED, LOW); delay(80); digitalWrite(LED_RED, HIGH); digitalWrite(LED_RED, HIGH); digitalWrite(LED_GREEN, HIGH); digitalWrite(LED_BLUE, HIGH);
while (!imuWakeFlag) { while (!imuWakeFlag && !btnWakeFlag) {
(void)lsmRead(SLP_WAKE_UP_SRC); (void)lsmRead(SLP_WAKE_UP_SRC);
sd_app_evt_wait(); sd_app_evt_wait();
} }
@@ -172,6 +186,11 @@ void sleepManagerWakeIMU() {
disarmWakeupInterrupt(); disarmWakeupInterrupt();
// Detach button wake interrupt — normal polling takes over
#if BTN_LEFT_PIN != BTN_PIN_NONE
detachInterrupt(digitalPinToInterrupt(BTN_LEFT_PIN));
#endif
// Only recalibrate if gyro was off long enough for thermal drift to accumulate, // Only recalibrate if gyro was off long enough for thermal drift to accumulate,
// or if waking from full deep sleep. Short LP naps reuse the existing bias. // or if waking from full deep sleep. Short LP naps reuse the existing bias.
unsigned long lpDuration = millis() - lpEnteredMs; unsigned long lpDuration = millis() - lpEnteredMs;
@@ -186,24 +205,9 @@ void sleepManagerWakeIMU() {
lpEnteredMs = 0; lpEnteredMs = 0;
// Reset motion filter state to prevent a cursor jump on the first frame. // Reset motion filter state to prevent a cursor jump on the first frame.
// After sleep: angleX/Y are stale, gravX/Y/Z drifted, accumX/Y is dirty,
// and lastTime is old so dt would be huge on the first loop iteration.
// Zeroing these here means the first frame integrates 0 motion cleanly.
extern float angleX, angleY;
extern float accumX, accumY; extern float accumX, accumY;
extern float gravX, gravY, gravZ;
extern float prevAx, prevAy, prevAz;
extern unsigned long lastTime; extern unsigned long lastTime;
angleX = angleY = 0.0f;
accumX = accumY = 0.0f; accumX = accumY = 0.0f;
// Reseed gravity from current accel so projection is correct immediately.
// Can't call imu.readFloat* here (gyro not fully settled), but accel is
// already running - read it directly via Wire1.
// Simpler: just reset to neutral [0,0,1] and let the LP filter converge
// over the first ~20 frames (200 ms) of real use.
gravX = 0.0f; gravY = 0.0f; gravZ = 1.0f;
prevAx = 0.0f; prevAy = 0.0f; prevAz = 0.0f;
// Set lastTime to now so the first dt = 0 rather than (now - sleepEntryTime)
lastTime = millis(); lastTime = millis();
sleepStage = SLEEP_AWAKE; sleepStage = SLEEP_AWAKE;
@@ -238,14 +242,23 @@ void sleepManagerInit() {
// Returns true → caller must skip IMU reads this iteration. // Returns true → caller must skip IMU reads this iteration.
bool sleepManagerUpdate(unsigned long nowMs, bool idle, bool bleConnected) { bool sleepManagerUpdate(unsigned long nowMs, bool idle, bool bleConnected) {
// ISR wakeup // ISR wakeup (IMU motion or button press)
bool woke = false;
if (imuWakeFlag) { if (imuWakeFlag) {
imuWakeFlag = false; imuWakeFlag = false;
Serial.print("[SLEEP] INT1 fired - stage="); Serial.println((int)sleepStage); Serial.print("[SLEEP] INT1 fired - stage="); Serial.println((int)sleepStage);
woke = true;
}
if (btnWakeFlag) {
btnWakeFlag = false;
Serial.print("[SLEEP] Button fired - stage="); Serial.println((int)sleepStage);
woke = true;
}
if (woke) {
if (sleepStage == SLEEP_DEEP || sleepStage == SLEEP_IMU_LP) { if (sleepStage == SLEEP_DEEP || sleepStage == SLEEP_IMU_LP) {
sleepManagerWakeIMU(); sleepManagerWakeIMU();
} else { } else {
(void)lsmRead(SLP_WAKE_UP_SRC); // normal-mode edge, clear latch only (void)lsmRead(SLP_WAKE_UP_SRC);
} }
} }
+2 -2
View File
@@ -11,12 +11,12 @@
// LSM6DS3 wakeup threshold: 1 LSB = 7.8 mg at ±2 g FS (±2g range). // LSM6DS3 wakeup threshold: 1 LSB = 7.8 mg at ±2 g FS (±2g range).
#ifndef SLEEP_WAKEUP_THS #ifndef SLEEP_WAKEUP_THS
#define SLEEP_WAKEUP_THS 16 // 063 #define SLEEP_WAKEUP_THS 6 // 063
#endif #endif
// Number of consecutive 26 Hz samples that must exceed the threshold. // Number of consecutive 26 Hz samples that must exceed the threshold.
#ifndef SLEEP_WAKEUP_DUR #ifndef SLEEP_WAKEUP_DUR
#define SLEEP_WAKEUP_DUR 2 // 03 #define SLEEP_WAKEUP_DUR 1 // 03
#endif #endif
// GPIO pin connected to LSM6DS3 INT1. // GPIO pin connected to LSM6DS3 INT1.
+6 -9
View File
@@ -137,14 +137,11 @@ async function discoverServices() {
await checkHashMatch(); await checkHashMatch();
// Telemetry notify (1 Hz) - also carries chargeStatus // Telemetry notify (1 Hz) - also carries chargeStatus
chars.telemetry.addEventListener('characteristicvaluechanged', e => parseTelemetry(e.target.value)); chars.telemetry.addEventListener('characteristicvaluechanged', e => {
console.log('[TELEM] subscribing to notifications'); const val = e.target.value; // capture immediately — Chrome reuses the DataView buffer on next notify
parseTelemetry(val);
});
await chars.telemetry.startNotifications(); await chars.telemetry.startNotifications();
console.log('[TELEM] subscribed, reading initial value');
// Initial read so values show immediately. Also force updateChargeUI() here
// because parseTelemetry() only calls it on a *change*, and currentChargeStatus
// starts at 0 (discharging) - so a discharging device would never trigger the
// update and ciStatus would stay at '--'.
parseTelemetry(await chars.telemetry.readValue()); parseTelemetry(await chars.telemetry.readValue());
updateChargeUI(); updateChargeUI();
@@ -447,16 +444,16 @@ function parseTelemetry(dv) {
const tEl = document.getElementById('telTemp'); const tEl = document.getElementById('telTemp');
tEl.className = 'telem-val '+(temp>40?'warn':'accent'); tEl.className = 'telem-val '+(temp>40?'warn':'accent');
// chargeStatus is now delivered via telemetry (no separate characteristic)
if (chargeStatus !== currentChargeStatus) { if (chargeStatus !== currentChargeStatus) {
currentChargeStatus = chargeStatus; currentChargeStatus = chargeStatus;
console.log('[TELEM] charge status:', ['discharging','charging','full'][chargeStatus] ?? chargeStatus);
updateChargeUI(); updateChargeUI();
} }
if (battVoltage !== null) { if (battVoltage !== null) {
currentBattVoltage = battVoltage; currentBattVoltage = battVoltage;
document.getElementById('ciVolt').textContent = battVoltage.toFixed(2) + 'V'; document.getElementById('ciVolt').textContent = battVoltage.toFixed(2) + 'V';
const pct = Math.round(Math.min(100, Math.max(0, (battVoltage - 3.00) / (4.20 - 3.00) * 100)));
if (pct !== currentBattPct) { currentBattPct = pct; updateBatteryBar(pct, currentChargeStatus); }
} }
} }
function formatUptime(s) { function formatUptime(s) {