Controller board: ESP32 + 2x StepStick carrier (v0.4), FluidNC config

- KiCad project generated by pcb/tools (design, route, schematic, fab outputs).
- v0.4 fixes the v0.3 review findings: low-dropout 3.3 V regulator, OR'd 5 V
  supply, polyfuse, USB ESD protection, input clamps, ceramic decoupling at the
  drivers, A4988/DRV8825 jumper, ground stitching. DRC/ERC clean.
- v0.3 (as ordered) kept in pcb/archive/v0.3.
- FluidNC config.yaml for the board; rho hard limit on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ckochandClaude Opus 5.5 committed 2026-11-01 13:40:23 -05:00
1 parent c498882bbb
commit 1400cc8afd
46 files changed
+226470

No files matched your search

+184
View File
@@ -0,0 +1,184 @@
"""Load footprints and symbols from the installed KiCad libraries."""
import math
import os
from sexpr import parse, find, findall, QStr
KICAD = os.environ.get('KICAD_SHARE', '/snap/kicad/current/usr/share/kicad')
FP_DIR = os.path.join(KICAD, 'footprints')
SYM_DIR = os.path.join(KICAD, 'symbols')
def load_footprint(lib_id, min_drill=None, min_ring=0.2, solid_thermal_pads=False):
lib, name = lib_id.split(':', 1)
path = os.path.join(FP_DIR, lib + '.pretty', name + '.kicad_mod')
with open(path) as f:
fp = parse(f.read())
if solid_thermal_pads:
_solidify_thermal_pads(fp)
if min_drill:
_enforce_min_drill(fp, min_drill, min_ring)
return fp
def _solidify_thermal_pads(fp):
"""Remove plated holes that sit inside a cluster of same-numbered SMD pads (thermal vias in an
exposed pad) and add one copper-only pad over the cluster, so the copper stays continuous under
the solder-mask/paste windows but no hole is left in a pad. Fabs flag holes in pads (solder
wicks into them) and charge for resin filling."""
import copy
pads = findall(fp, 'pad')
by_num = {}
for p in pads:
by_num.setdefault(str(p[1]), []).append(p)
for num, group in by_num.items():
smd = [p for p in group if str(p[2]) == 'smd']
tht = [p for p in group if str(p[2]) == 'thru_hole']
if len(smd) < 2 or not tht:
continue
boxes = []
for p in smd:
at, size = find(p, 'at'), find(p, 'size')
x, y, w, h = float(at[1]), float(at[2]), float(size[1]), float(size[2])
boxes.append((x - w / 2, y - h / 2, x + w / 2, y + h / 2))
x0, y0 = min(b[0] for b in boxes), min(b[1] for b in boxes)
x1, y1 = max(b[2] for b in boxes), max(b[3] for b in boxes)
inside = [p for p in tht if x0 - 0.5 <= float(find(p, 'at')[1]) <= x1 + 0.5
and y0 - 0.5 <= float(find(p, 'at')[2]) <= y1 + 0.5]
if not inside:
continue
for p in inside:
fp.remove(p)
solid = copy.deepcopy(smd[0])
solid[:] = [v for v in solid if not (isinstance(v, list) and v and v[0] in ('uuid', 'roundrect_rratio'))]
solid[3] = 'rect'
at, size, layers = find(solid, 'at'), find(solid, 'size'), find(solid, 'layers')
at[1:] = [f'{(x0 + x1) / 2:g}', f'{(y0 + y1) / 2:g}']
size[1:] = [f'{x1 - x0:g}', f'{y1 - y0:g}']
layers[1:] = [QStr('F.Cu')]
fp.insert(fp.index(smd[0]), solid)
def _enforce_min_drill(fp, min_drill, min_ring):
"""Grow round plated holes below min_drill (and their copper ring) so the board fits the fab's process."""
for p in findall(fp, 'pad'):
if str(p[2]) != 'thru_hole':
continue
drill = find(p, 'drill')
if not drill or len(drill) < 2 or isinstance(drill[1], list) or str(drill[1]) == 'oval':
continue
if float(drill[1]) >= min_drill - 1e-9:
continue
drill[1] = f'{min_drill:g}'
size = find(p, 'size')
need = min_drill + 2 * min_ring
for i in (1, 2):
if float(size[i]) < need:
size[i] = f'{need:g}'
def fp_pads(fp):
"""Pads as dicts in footprint-local coords (KiCad: y down)."""
pads = []
for p in findall(fp, 'pad'):
num = str(p[1])
kind = p[2] # smd / thru_hole / np_thru_hole / connect
shape = p[3]
at = find(p, 'at')
x, y = float(at[1]), float(at[2])
rot = float(at[3]) if len(at) > 3 else 0.0
size = find(p, 'size')
w, h = float(size[1]), float(size[2])
layers = [str(l) for l in find(p, 'layers')[1:]]
drill = find(p, 'drill')
d = None
if drill:
vals = [v for v in drill[1:] if not isinstance(v, list)]
if vals and vals[0] == 'oval':
d = (float(vals[1]), float(vals[2]) if len(vals) > 2 else float(vals[1]))
elif vals:
d = (float(vals[0]), float(vals[0]))
pads.append(dict(num=num, kind=kind, shape=shape, x=x, y=y, rot=rot,
w=w, h=h, layers=layers, drill=d, node=p))
return pads
def fp_courtyard(fp):
"""Bounding box (x0,y0,x1,y1) of the front/back courtyard lines/rects."""
xs, ys = [], []
for kind in ('fp_line', 'fp_rect', 'fp_circle', 'fp_arc', 'fp_poly'):
for g in findall(fp, kind):
layer = find(g, 'layer')
if not layer or 'CrtYd' not in str(layer[1]):
continue
for key in ('start', 'end', 'mid', 'center'):
pt = find(g, key)
if pt:
xs.append(float(pt[1])); ys.append(float(pt[2]))
pts = find(g, 'pts')
if pts:
for xy in findall(pts, 'xy'):
xs.append(float(xy[1])); ys.append(float(xy[2]))
if kind == 'fp_circle':
c = find(g, 'center'); e = find(g, 'end')
r = math.hypot(float(e[1]) - float(c[1]), float(e[2]) - float(c[2]))
xs += [float(c[1]) - r, float(c[1]) + r]
ys += [float(c[2]) - r, float(c[2]) + r]
if not xs:
return None
return (min(xs), min(ys), max(xs), max(ys))
_symlib_cache = {}
def load_symbol(lib_id):
"""Return the symbol definition list (with 'extends' resolved)."""
lib, name = lib_id.split(':', 1)
if lib not in _symlib_cache:
with open(os.path.join(SYM_DIR, lib + '.kicad_sym')) as f:
_symlib_cache[lib] = parse(f.read())
root = _symlib_cache[lib]
syms = {str(s[1]): s for s in findall(root, 'symbol')}
s = syms[name]
ext = find(s, 'extends')
if ext:
base = load_symbol(lib + ':' + str(ext[1]))
# copy base, take name + properties from derived
import copy
merged = copy.deepcopy(base)
merged[1] = QStr(name)
# rename sub-units
for sub in findall(merged, 'symbol'):
sub[1] = QStr(str(sub[1]).replace(str(ext[1]), name, 1))
# properties: inherited from the base, overridden/extended by the derived symbol
base_props = findall(merged, 'property')
merged = [x for x in merged if not (isinstance(x, list) and x and x[0] == 'property')]
derived = {str(p[1]): p for p in findall(s, 'property')}
props = []
for p in base_props:
props.append(derived.pop(str(p[1]), p))
props += list(derived.values())
# insert properties after the symbol-level flags
idx = 2
while idx < len(merged) and isinstance(merged[idx], list) and merged[idx][0] in ('pin_names', 'pin_numbers', 'exclude_from_sim', 'in_bom', 'on_board'):
idx += 1
merged = merged[:idx] + props + merged[idx:]
return merged
return s
def sym_pins(sym):
"""Pins (number, name, type, x, y, angle, length) in symbol coords (y up)."""
pins = []
for unit in findall(sym, 'symbol'):
for p in findall(unit, 'pin'):
at = find(p, 'at')
length = float(find(p, 'length')[1])
name = find(p, 'name')
number = find(p, 'number')
pins.append(dict(type=str(p[1]), style=str(p[2]),
x=float(at[1]), y=float(at[2]), angle=float(at[3]) if len(at) > 3 else 0.0,
length=length, name=str(name[1]), num=str(number[1]),
hidden=('hide' in [str(v) for v in p if not isinstance(v, list)]),
unit=str(unit[1])))
return pins