board
Wrist Health Tracker
Public Unreviewedby TylerK
A wrist-worn nRF52840 health tracker for sleep-quality scoring: heart rate, HRV, SpO2, steps and skin temperature, logged to flash and synced over BLE to a home server. Four-layer 32 x 30 mm KiCad board with single-sided assembly, a 3D-printed 35.7 x 33.9 x 9.95 mm case and a magnetic pogo-pin dock.
main
TylerK
Wrist Health Tracker: KiCad project, fabrication files, 3D models, enclosure and dock CAD, design docs
d433b2a
14h ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
#!/usr/bin/env python3
"""Full assembly for viewing: the device as worn (enclosure parts, the routed board with every
component, the cell, magnets, screws, thermal pad, spring bars and a 20 mm strap), and the dock
beside it with its pogo pins, magnets and VBUS damping capacitor, and a second dock with the device seated in it.
Run enclosure.py first.
The exploded copy (50 mm to the -x side) shows the build order: the cell is taped into its pocket, then the
board (soldered to the cell's leads) drops in from the skin side.
Writes out/full_assembly.step (open in Fusion: File > Open, or Insert > Insert into current design)
and out/full_assembly.glb (any glTF viewer). Frame: X = board x, Y = -board y, +Z = skin side."""
import os
import cadquery as cq
from enclosure import (OUT, BOARD_STEP, BW, BH, BT, GAP, WALL_Y, Z_PLATE_IN, Z_PLATE_OUT, SCREWS, LUG_W,
BAR_FROM_WALL, BAR_Z, MAGNETS, MAG_D, MAG_H, MAG_RECESS, J1_PADS, U4, U4_TOP, POST_D, TPAD,
SCREW_PILOT, SCREW_HEAD, PAD_Z, TONGUE_TOP, cyl, slab, cell, cell_leads)
DOCK_DX = 50.0 # dock shown beside the device, along +x
C = {
"body": (0.17, 0.18, 0.21), "back_plate": (0.06, 0.06, 0.07), "pipe_pd": (0.72, 0.88, 1.0),
"pipe_led": (0.72, 0.88, 1.0), "thermal_post": (0.80, 0.81, 0.84), "led_rod_1": (0.85, 0.95, 1.0),
"led_rod_2": (0.85, 0.95, 1.0), "button_cap": (0.33, 0.34, 0.38), "dock_cradle": (0.86, 0.87, 0.89),
"pcb": (0.06, 0.36, 0.18), "module": (0.82, 0.80, 0.74), "part": (0.20, 0.20, 0.22),
"cell": (0.66, 0.70, 0.78), "magnet": (0.62, 0.62, 0.66), "screw": (0.72, 0.72, 0.75),
"tpad": (0.95, 0.55, 0.20), "bar": (0.75, 0.75, 0.78), "strap": (0.12, 0.13, 0.16),
"pogo": (0.85, 0.70, 0.25), "cap": (0.15, 0.25, 0.55), "lead_red": (0.80, 0.12, 0.10),
"lead_black": (0.10, 0.10, 0.10),
}
def color(k, a=1.0):
return cq.Color(*C[k], a)
def load(name):
return cq.importers.importStep(os.path.join(OUT, f"{name}.step"))
def screw(x, y):
"""M1.2 x 4 countersunk: 90 deg head flush with the skin face, shank into the body pilot."""
h = SCREW_HEAD / 2 - 0.6
head = (cq.Workplane("XY").workplane(offset=Z_PLATE_OUT - h).center(x, -y)
.circle(0.6).workplane(offset=h).circle(SCREW_HEAD / 2 - 0.05).loft())
shank = cyl(x, y, SCREW_PILOT, Z_PLATE_OUT - 4.0, Z_PLATE_OUT - h + 0.01) # drawn at the thread root (= pilot)
return head.union(shank).cut(slab(x - 0.1, y - 0.9, x + 0.1, y + 0.9, Z_PLATE_OUT - 0.3, Z_PLATE_OUT + 0.1))
def magnet(x, y):
"""Ø3 x 1.5 epoxy-coated magnet in its open pocket, 0.1 below the skin face."""
return cyl(x, y, MAG_D, Z_PLATE_OUT - MAG_RECESS - MAG_H, Z_PLATE_OUT - MAG_RECESS)
def spring_bar(yb, out_sign=0):
"""1.5 mm quick-release spring bar between the lug inner faces, 0.9 mm tips in the 1.1 mm lug holes,
drawn where the strap's pull puts it: tips bearing on the outer side of the holes."""
yb = yb + out_sign * (0.55 - 0.45)
x0, x1 = BW / 2 - LUG_W / 2, BW / 2 + LUG_W / 2
bar = cq.Workplane("YZ").workplane(offset=x0 + 0.05).center(-yb, BAR_Z).circle(0.75).extrude(x1 - x0 - 0.1)
tips = cq.Workplane("YZ").workplane(offset=x0 - 1.6).center(-yb, BAR_Z).circle(0.45).extrude(x1 - x0 + 3.2)
return bar.union(tips)
def strap(yb, out_sign, R=24.0, t=2.2, sweep=100):
"""One half of a 20 mm band: loops the spring bar, then curves round the wrist (toward +Z)."""
yg = -yb
w = LUG_W - 0.4
x0 = BW / 2 - w / 2
loop = (cq.Workplane("YZ").workplane(offset=x0).center(yg, BAR_Z).circle(0.75 + t).circle(0.80)
.extrude(w))
zc = BAR_Z + 0.75 + R + t / 2 # band centre-line starts tangent under the bar
ring = (cq.Workplane("YZ").workplane(offset=x0).center(yg, zc).circle(R + t / 2).circle(R - t / 2)
.extrude(w))
# keep the arc that leaves the bar outward (away from the case) and climbs toward the wrist
big = 200
keep = cq.Workplane("XY").box(big, big, big).translate((BW / 2, yg + out_sign * big / 2, zc - big / 2 + 0.01))
band = ring.intersect(keep)
if sweep > 90: # carry on past the quarter, up the wrist
extra = (cq.Workplane("YZ").workplane(offset=x0)
.center(yg + out_sign * (R), zc).rect(t, 2 * R * 0.18).extrude(w)
.translate((0, 0, R * 0.18)))
band = band.union(extra)
half_loop = loop.cut(cq.Workplane("XY").box(big, big, big).translate((BW / 2, yg - out_sign * big / 2, 0)))
return band.union(half_loop)
def board_groups():
"""The KiCad STEP (routed board + 3D models), split into PCB / module / other parts for colour."""
groups = {"pcb": [], "module": [], "part": []}
for s in cq.importers.importStep(BOARD_STEP).val().Solids():
bb = s.BoundingBox()
if bb.xlen > 30 and bb.ylen > 28:
groups["pcb"].append(s)
elif bb.zmax > 3.5:
groups["module"].append(s)
else:
groups["part"].append(s)
return {k: cq.Compound.makeCompound(v) for k, v in groups.items() if v}
def exploded(board):
"""Build order, pulled apart along Z (the skin side is up): 1 body (button cap pushed into its
recess from inside), 2 the cell, taped into its pocket, leads laid in the channel, 3 the board,
soldered to the leads, then dropped straight down onto the ledge, 4 the back plate with pipes, post,
rods and magnets, 5 screws."""
ex = cq.Assembly(name="device_exploded_build_order", loc=cq.Location(cq.Vector(-DOCK_DX, 0, 0)))
def put(shape, name, col, dz, dx=0.0, alpha=1.0):
ex.add(shape, name=name, color=color(col, alpha), loc=cq.Location(cq.Vector(dx, 0, dz)))
put(load("body"), "1_body", "body", 0)
put(load("button_cap"), "1_button_cap", "button_cap", 0, dx=-6)
put(cell(), "2_lipo_cell", "cell", 12)
for k, comp in board.items():
put(comp, f"3_board_{k}", k, 20)
put(slab(U4[0] - 1.0, U4[1] - 1.0, U4[0] + 1.0, U4[1] + 1.0, U4_TOP, U4_TOP + TPAD), "3_thermal_pad", "tpad", 26)
put(load("back_plate"), "4_back_plate", "back_plate", 32)
for n in ("pipe_pd", "pipe_led", "thermal_post", "led_rod_1", "led_rod_2"):
put(load(n), f"4_{n}", n, 40)
for i, (x, y) in enumerate(MAGNETS):
put(magnet(x, y), f"4_magnet_{i + 1}", "magnet", 40)
for i, (x, y) in enumerate(SCREWS):
put(screw(x, y), f"5_screw_{i + 1}", "screw", 46)
return ex
def main():
dev = cq.Assembly(name="device")
for n in ("body", "back_plate", "pipe_pd", "pipe_led", "thermal_post", "led_rod_1", "led_rod_2", "button_cap"):
dev.add(load(n), name=n, color=color(n, 0.55 if n == "body" else 1.0))
brd = cq.Assembly(name="board")
groups = board_groups()
for k, comp in groups.items():
brd.add(comp, name={"pcb": "pcb_routed", "module": "nrf52840_module", "part": "components"}[k], color=color(k))
dev.add(brd)
dev.add(cell(), name="lipo_302025_110mAh", color=color("cell"))
for w, nm in zip(cell_leads(), ("lead_minus", "lead_plus")):
dev.add(w, name=nm, color=color("lead_black" if nm == "lead_minus" else "lead_red"))
dev.add(slab(U4[0] - 1.0, U4[1] - 1.0, U4[0] + 1.0, U4[1] + 1.0, U4_TOP, U4_TOP + TPAD), name="thermal_pad",
color=color("tpad"))
for i, (x, y) in enumerate(MAGNETS):
dev.add(magnet(x, y), name=f"magnet_{i + 1}", color=color("magnet"))
for i, (x, y) in enumerate(SCREWS):
dev.add(screw(x, y), name=f"screw_M1.2x4_{i + 1}", color=color("screw"))
band = cq.Assembly(name="strap_20mm")
for sgn, nm in ((-1, "6_oclock"), (1, "12_oclock")):
yb = -GAP - WALL_Y - BAR_FROM_WALL if sgn < 0 else BH + GAP + WALL_Y + BAR_FROM_WALL
band.add(spring_bar(yb, sgn), name=f"spring_bar_{nm}", color=color("bar"))
band.add(strap(yb + sgn * 0.1, out_sign=-sgn), name=f"band_{nm}", color=color("strap"))
dev.add(band)
dock = cq.Assembly(name="dock", loc=cq.Location(cq.Vector(DOCK_DX, 0, 0)))
dock.add(load("dock_cradle"), name="dock_cradle", color=color("dock_cradle"))
zf = Z_PLATE_OUT
for i, (x, y) in enumerate(J1_PADS):
# P75 pin drawn free (not docked): plunger tip 1.8 mm above the tongue, barrel pressed into the tongue
pin = cyl(x, y, 1.02, TONGUE_TOP + 0.4, zf + 8.5).union(cyl(x, y, 0.74, TONGUE_TOP - 1.8, TONGUE_TOP + 0.4))
dock.add(pin, name=f"pogo_{['VBUS', 'D-', 'D+', 'GND'][i]}", color=color("pogo"))
for i, (x, y) in enumerate(MAGNETS):
dock.add(cyl(x, y, MAG_D, zf + MAG_RECESS, zf + MAG_RECESS + MAG_H), name=f"dock_magnet_{i + 1}",
color=color("magnet"))
can = cyl(21.9, 28.9, 6.3, zf + 2.5, zf + 7.9) # glued against the pocket ceiling, leads out the bottom
dock.add(can, name="C_damp_100uF_10V_electrolytic", color=color("cap"))
# a second dock, with the device seated in it (pins compressed onto the J1 pads), beside the first
docked = cq.Assembly(name="device_on_dock", loc=cq.Location(cq.Vector(2 * DOCK_DX, 0, 0)))
docked.add(load("dock_cradle"), name="dock_cradle", color=color("dock_cradle"))
for n in ("body", "back_plate", "pipe_pd", "pipe_led", "thermal_post", "led_rod_1", "led_rod_2", "button_cap"):
docked.add(load(n), name=n, color=color(n))
for k, comp in groups.items():
docked.add(comp, name=f"board_{k}", color=color(k))
docked.add(cell(), name="lipo_302025_110mAh", color=color("cell"))
for i, (x, y) in enumerate(J1_PADS):
pin = cyl(x, y, 1.02, TONGUE_TOP + 0.4, zf + 8.5).union(cyl(x, y, 0.74, PAD_Z, TONGUE_TOP + 0.4))
docked.add(pin, name=f"pogo_{i + 1}_on_pad", color=color("pogo"))
for i, (x, y) in enumerate(MAGNETS):
docked.add(magnet(x, y), name=f"magnet_{i + 1}", color=color("magnet"))
docked.add(cyl(x, y, MAG_D, zf + MAG_RECESS, zf + MAG_RECESS + MAG_H), name=f"dock_magnet_{i + 1}",
color=color("magnet"))
top = cq.Assembly(name="health_tracker")
top.add(dev)
top.add(dock)
top.add(docked)
top.add(exploded(groups))
step = os.path.join(OUT, "full_assembly.step")
top.save(step)
try:
top.save(os.path.join(OUT, "full_assembly.glb"))
except Exception as e: # older CadQuery: GLB export not available
print("glb skipped:", e)
print("wrote", step)
if __name__ == "__main__":
main()