#!/usr/bin/env python3 """ EinarBoard 3.0 — Phase B generator: PCB placement for hbox-watchdog-v5.kicad_pcb File-level edits per standing rule. Removes Blackpill sockets + tach parts, swaps P6 to JST B2B 1x02, fabricates+places all Phase-A footprints (dims from KiCad v7 lib files, syntax from same-board templates), moves Y1/Y2, adds Block-Q mounting holes, updates net table + title date. Verifies: courtyard overlaps, pad-net consistency, board bounds. """ import re, sys, math, uuid, collections from decimal import Decimal as D PCB = sys.argv[1] if len(sys.argv) > 1 else 'hbox-watchdog-v5.kicad_pcb' SCH = sys.argv[2] if len(sys.argv) > 2 else 'hbox-watchdog-v5.kicad_sch' LIB = '/usr/share/kicad/footprints' src = open(PCB, encoding='utf-8').read() if '"U16"' in src: print('ABORT: U16 already on board'); sys.exit(1) def NU(): return str(uuid.uuid4()) def endof(s, start): d = 0; ins = False; esc = False for k in range(start, len(s)): c = s[k] if esc: esc = False; continue if c == '\\': esc = True; continue if c == '"': ins = not ins; continue if not ins: if c == '(': d += 1 elif c == ')': d -= 1 if d == 0: return k + 1 return -1 # ---------------- board facts ---------------- X0, X1, Y0, Y1 = 60.19165, 215.19165, 40.3231, 140.3231 # ---------------- net table ---------------- nets = set() for m in re.finditer(r'\(net "((?:[^"\\]|\\.)*)"\)', src): nets.add(m.group(1)) NEW_NETS = ['DFU_VBUS','DFU_DP','DFU_DM','DFU_CC1','DFU_CC2','LDO_IN','BOOT0','BOOT1_STRAP', 'VCAP1','VDDA','HSE_IN','HSE_OUT','LSE_IN','LSE_OUT','SWDIO','SWCLK', 'LED_C13','LED_C13_A','LED_PWR_A'] def resolve(name): if name in nets: return name if '/'+name in nets: return '/'+name return None def N(name): r = resolve(name) return f'(net "{r if r else "/"+name}")' # new nets follow the local-label "/" convention addnets = [n for n in NEW_NETS if resolve(n) is None] for req in ['NRST','3V3','SYS5V','GND','ETH_INT','5V_FAN','FAN_RET']: assert resolve(req), f'expected existing net {req}' # ---------------- footprint blocks ---------------- fp_blocks = [] # (ref, at(x,y,rot), span, block) for m in re.finditer(r'\(footprint\s+"([^"]+)"', src): a = m.start(); e = endof(src, a); blk = src[a:e] rm = re.search(r'\(property "Reference"\s+"([^"]+)"', blk) am = re.search(r'\(at ([\-0-9.]+) ([\-0-9.]+)( [\-0-9.]+)?\)', blk) if rm and am: fp_blocks.append(dict(ref=rm.group(1), lib=m.group(1), x=float(am.group(1)), y=float(am.group(2)), rot=float((am.group(3) or '0')), span=(a, e), blk=blk)) def by_ref(r): c = [f for f in fp_blocks if f['ref'] == r] assert len(c) == 1, f'{r}: {len(c)}' return c[0] def pads_of(blk): out = [] for m in re.finditer(r'\(pad\s+"([^"]*)"\s+(np_thru_hole|thru_hole|smd)\s+(\w+)', blk): a = m.start(); e = endof(blk, a); p = blk[a:e] am = re.search(r'\(at ([\-0-9.]+) ([\-0-9.]+)( [\-0-9.]+)?\)', p) nm = re.search(r'\(net "((?:[^"\\]|\\.)*)"\)', p) sm = re.search(r'\(size ([\-0-9.]+) ([\-0-9.]+)\)', p) out.append(dict(num=m.group(1), kind=m.group(2), shape=m.group(3), lx=float(am.group(1)), ly=float(am.group(2)), rot=float((am.group(3) or '0')), net=nm.group(1) if nm else None, sx=float(sm.group(1)) if sm else 1, sy=float(sm.group(2)) if sm else 1, span=(a, e), txt=p)) return out def rotpt(lx, ly, deg): r = math.radians(deg % 360) return (lx*math.cos(r) - ly*math.sin(r), lx*math.sin(r) + ly*math.cos(r)) # validate rotation convention with P12 (rot 90 B2B) vs its connected tracks P12 = by_ref('P12') p12p = pads_of(P12['blk']) cands = {} for sgn in (1, -1): pts = [] for p in p12p: dx, dy = rotpt(p['lx'], p['ly'], sgn*P12['rot']) pts.append((round(P12['x']+dx, 3), round(P12['y']+dy, 3))) cands[sgn] = pts seg_pts = set() for m in re.finditer(r'\(segment\s*\(start ([\-0-9.]+) ([\-0-9.]+)\)\s*\(end ([\-0-9.]+) ([\-0-9.]+)\)', src): seg_pts.add((round(float(m.group(1)), 3), round(float(m.group(2)), 3))) seg_pts.add((round(float(m.group(3)), 3), round(float(m.group(4)), 3))) score = {1: 0, -1: 0} segnet = collections.defaultdict(set) for m in re.finditer(r'\(segment\s*\(start ([\-0-9.]+) ([\-0-9.]+)\)\s*\(end ([\-0-9.]+) ([\-0-9.]+)\)(.*?)\(net "((?:[^"\\]|\\.)*)"\)', src, re.S): nn = m.group(6) segnet[nn].add((round(float(m.group(1)), 2), round(float(m.group(2)), 2))) segnet[nn].add((round(float(m.group(3)), 2), round(float(m.group(4)), 2))) for f in fp_blocks: if not f['rot'] % 360: continue for p in pads_of(f['blk']): if not p['net'] or p['net'] not in segnet: continue for sgn in (1, -1): dx, dy = rotpt(p['lx'], p['ly'], sgn*f['rot']) pt = (round(f['x']+dx, 2), round(f['y']+dy, 2)) if pt in segnet[p['net']]: score[sgn] += 1 ROTSGN = 1 if score[1] >= score[-1] else -1 print(f'rotation convention: sign={ROTSGN} (net-aware pad-track hits {score})') def pad_world(f, p): dx, dy = rotpt(p['lx'], p['ly'], ROTSGN*f['rot']) return (f['x']+dx, f['y']+dy) # ---------------- courtyard/occupancy map (AABB per footprint from pads + courtyard rects) ---------------- def fp_bbox(f): xs = []; ys = [] for p in pads_of(f['blk']): wx, wy = pad_world(f, p) xs += [wx - p['sx']/2, wx + p['sx']/2]; ys += [wy - p['sy']/2, wy + p['sy']/2] for m in re.finditer(r'\(fp_(?:rect|line|circle|poly)\b', f['blk']): e = endof(f['blk'], m.start()); g = f['blk'][m.start():e] if '"F.CrtYd"' not in g and '"B.CrtYd"' not in g: continue for mm in re.finditer(r'\((?:start|end|center|xy) ([\-0-9.]+) ([\-0-9.]+)\)', g): dx, dy = rotpt(float(mm.group(1)), float(mm.group(2)), ROTSGN*f['rot']) xs.append(f['x']+dx); ys.append(f['y']+dy) if not xs: return None return (min(xs), min(ys), max(xs), max(ys)) REMOVE = {'J1', 'J2', 'Rts1', 'Rtp1', 'P6'} occ = [] for f in fp_blocks: if f['ref'] in REMOVE or f['ref'] in ('Y1', 'Y2'): continue if f['ref'] == 'MOD1': # header rows + USB/barrel-corner overhang keepout only; under-shield SMD is allowed pws = [pad_world(f, p) for p in pads_of(f['blk'])] ys = sorted({round(w[1], 1) for w in pws}) xs = [w[0] for w in pws] rowA, rowB = min(ys), max(ys) occ.append(('MOD1-rowA', (min(xs)-1, rowA-1, max(xs)+1, rowA+1))) occ.append(('MOD1-rowB', (min(xs)-1, rowB-1, max(xs)+1, rowB+1))) occ.append(('MOD1-ovh', (min(xs)-10, rowB-18.5, min(xs)-0.5, rowB-4.5))) continue bb = fp_bbox(f) if bb: occ.append((f['ref'], bb)) def colliders(bb, margin=0.3): x0, y0, x1, y1 = bb hits = [] if x0 < X0+0.8 or y0 < Y0+0.8 or x1 > X1-0.8 or y1 > Y1-0.8: hits.append('BOARD-EDGE') for ref, (a0, b0, a1, b1) in occ: if not (x1+margin < a0 or a1+margin < x0 or y1+margin < b0 or b1+margin < y0): hits.append(ref) return hits def clear(bb, margin=0.3): return not colliders(bb, margin) def place(w, h, ax, ay, fixed=False, label=''): """find position for a w×h courtyard near anchor; returns center""" if fixed: bb = (ax-w/2, ay-h/2, ax+w/2, ay+h/2) assert clear(bb), f'FIXED position blocked: {label} at {ax},{ay}' occ.append((label, bb)); return (ax, ay) step = 0.635 for ring in range(0, 40): for dx in range(-ring, ring+1): for dy in range(-ring, ring+1): if max(abs(dx), abs(dy)) != ring: continue cx, cy = ax + dx*step, ay + dy*step bb = (cx-w/2, cy-h/2, cx+w/2, cy+h/2) if clear(bb): occ.append((label, bb)); return (round(cx, 2), round(cy, 2)) raise RuntimeError(f'no space for {label} near {ax},{ay}') # ---------------- sch symbol uuid map (for footprint path linkage) ---------------- schsrc = open(SCH, encoding='utf-8').read() sch_uuid = {} for m in re.finditer(r'\(symbol\b', schsrc): e = endof(schsrc, m.start()); b = schsrc[m.start():e] um = re.search(r'\(uuid "([^"]+)"\)', b) rm = re.search(r'\(property "Reference"\s+"([^"]+)"', b) if um and rm and rm.group(1) not in sch_uuid: sch_uuid[rm.group(1)] = um.group(1) # ---------------- v7 lib dims ---------------- def lib_dims(path): s = open(path).read() pads = [] for m in re.finditer(r'\(pad\s+"?([^"\s]*)"?\s+(np_thru_hole|thru_hole|smd)\s+(\w+)', s): e = endof(s, m.start()); p = s[m.start():e] am = re.search(r'\(at ([\-0-9.]+) ([\-0-9.]+)( [\-0-9.]+)?\)', p) sm = re.search(r'\(size ([\-0-9.]+) ([\-0-9.]+)\)', p) dm = re.search(r'\(drill ([\-0-9.]+)', p) rr = re.search(r'\(roundrect_rratio ([\-0-9.]+)\)', p) lay = re.search(r'\(layers ([^)]+)\)', p) pads.append(dict(num=m.group(1).strip('"'), kind=m.group(2), shape=m.group(3), x=float(am.group(1)), y=float(am.group(2)), rot=float((am.group(3) or '0')), sx=float(sm.group(1)), sy=float(sm.group(2)), drill=float(dm.group(1)) if dm else None, rratio=rr.group(1) if rr else None, layers=lay.group(1))) cxs = []; cys = [] for m in re.finditer(r'\(fp_(?:rect|line|circle)\b', s): e = endof(s, m.start()); g = s[m.start():e] if 'CrtYd' not in g: continue for mm in re.finditer(r'\((?:start|end|center) ([\-0-9.]+) ([\-0-9.]+)\)', g): cxs.append(float(mm.group(1))); cys.append(float(mm.group(2))) crt = (min(cxs), min(cys), max(cxs), max(cys)) if cxs else None return dict(pads=pads, crt=crt) LQFP64 = lib_dims(f'{LIB}/Package_QFP.pretty/LQFP-64_10x10mm_P0.5mm.kicad_mod') SOT223 = lib_dims(f'{LIB}/Package_TO_SOT_SMD.pretty/SOT-223-3_TabPin2.kicad_mod') SOD323 = lib_dims(f'{LIB}/Diode_SMD.pretty/D_SOD-323.kicad_mod') X3215 = lib_dims(f'{LIB}/Crystal.pretty/Crystal_SMD_3215-2Pin_3.2x1.5mm.kicad_mod') LED063 = lib_dims(f'{LIB}/LED_SMD.pretty/LED_0603_1608Metric.kicad_mod') UNOH = lib_dims(f'{LIB}/Module.pretty/Arduino_UNO_R3_WithMountingHoles.kicad_mod') HOLES_LOCAL = [(p['x'], p['y']) for p in UNOH['pads'] if p['kind'] == 'np_thru_hole'] assert len(HOLES_LOCAL) == 4 # MOD1 transform fit: board pad locals vs library pad locals (plain UNO, pad1..32) UNO = lib_dims(f'{LIB}/Module.pretty/Arduino_UNO_R3.kicad_mod') MOD1 = by_ref('MOD1') bp = {p['num']: (p['lx'], p['ly']) for p in pads_of(MOD1['blk'])} lp = {p['num']: (p['x'], p['y']) for p in UNO['pads']} common = [k for k in ('1', '2', '11', '20', '32') if k in bp and k in lp] assert len(common) >= 3, f'MOD1 pads for fit: {common}' # solve: board_local = (sx * lib_x, sy * lib_y) with sx,sy in {+1,-1} + offset fits = [] for sx in (1, -1): for sy in (1, -1): offs = {(round(bp[k][0]-sx*lp[k][0], 3), round(bp[k][1]-sy*lp[k][1], 3)) for k in common} if len(offs) == 1: fits.append((sx, sy, offs.pop())) assert len(fits) == 1, f'MOD1 fit ambiguous: {fits}' MSX, MSY, (MOX, MOY) = fits[0] print(f'MOD1 mirror fit: sx={MSX} sy={MSY} off=({MOX},{MOY})') shield_holes = [] for hx, hy in HOLES_LOCAL: blx, bly = MSX*hx + MOX, MSY*hy + MOY dx, dy = rotpt(blx, bly, ROTSGN*MOD1['rot']) shield_holes.append((round(MOD1['x']+dx, 3), round(MOD1['y']+dy, 3))) print('shield bolt holes (world):', shield_holes) # ---------------- templates from board ---------------- T_R = by_ref('R20'); T_C = None; T_C0805 = None for f in fp_blocks: if 'C_0603' in f['lib'] and T_C is None: T_C = f if 'C_0805' in f['lib'] and T_C0805 is None: T_C0805 = f assert T_C, 'no 0603 C template' T_SMA = by_ref('D2'); T_TP = by_ref('TP1'); T_SW = by_ref('SW1'); T_MH = by_ref('MH2') T_B2B = by_ref('P12'); T_USB = by_ref('J9'); T_LQFP = by_ref('U11'); T_HC49 = by_ref('Y1') P6old = by_ref('P6') def path_of(blk): m = re.search(r'\(path "([^"]+)"\)', blk) return m.group(1) if m else None def retarget(tmpl_f, ref, value, libname, x, y, rot, padnets, fpname=None): """clone a board template block: new ref/value/lib/at/uuids/nets/path""" t = tmpl_f['blk'] t = re.sub(r'\(footprint\s+"[^"]+"', f'(footprint "{libname}"', t, count=1) t = re.sub(r'\(at [\-0-9. ]+\)', f'(at {x:g} {y:g}{(" %g"%rot) if rot else ""})', t, count=1) t = re.sub(r'\(property "Reference"\s+"[^"]+"', f'(property "Reference" "{ref}"', t, count=1) t = re.sub(r'\(property "Value"\s+"[^"]*"', f'(property "Value" "{value}"', t, count=1) t = re.sub(r'\(property "Footprint"\s+"[^"]*"', f'(property "Footprint" "{libname}"', t, count=1) t = re.sub(r'\(uuid "[^"]*"\)', lambda m: f'(uuid "{NU()}")', t) su = sch_uuid.get(ref) if su: t = re.sub(r'\(path "[^"]*"\)', f'(path "/{su}")', t, count=1) # pad rotation update: template rot -> new rot on every pad angle token + nets dr = rot - tmpl_f['rot'] def fixpad(m): a = m.start(); e = endof(t, a); p = t[a:e] num = re.match(r'\(pad\s+"([^"]*)"', p).group(1) if dr: def bump(mm): ang = (float(mm.group(3) or 0) + dr) % 360 return f'(at {mm.group(1)} {mm.group(2)}{(" %g"%ang) if ang else ""})' p2 = re.sub(r'\(at ([\-0-9.]+) ([\-0-9.]+)(?: ([\-0-9.]+))?\)', bump, p, count=1) else: p2 = p if num in padnets: if '(net ' in p2: p2 = re.sub(r'\(net "(?:[^"\\]|\\.)*"\)', N(padnets[num]), p2) else: p2 = p2[:-1] + '\n\t\t' + N(padnets[num]) + '\n\t)' else: p2 = re.sub(r'\s*\(net "(?:[^"\\]|\\.)*"\)', '', p2) return p2 out = []; i = 0 for m in list(re.finditer(r'\(pad\s+"[^"]*"', t)): a = m.start() if a < i: continue e = endof(t, a) out.append(t[i:a]); # rebuild pad via fixpad logic inline p = t[a:e] num = re.match(r'\(pad\s+"([^"]*)"', p).group(1) if dr: def bump(mm): ang = (float(mm.group(3) or 0) + dr) % 360 return f'(at {mm.group(1)} {mm.group(2)}{(" %g"%ang) if ang else ""})' p = re.sub(r'\(at ([\-0-9.]+) ([\-0-9.]+)(?: ([\-0-9.]+))?\)', bump, p, count=1) if num in padnets: if '(net ' in p: p = re.sub(r'\(net "(?:[^"\\]|\\.)*"\)', N(padnets[num]), p) else: p = p[:-1] + '\n\t\t\t' + N(padnets[num]) + '\n\t\t)' else: p = re.sub(r'\s*\(net "(?:[^"\\]|\\.)*"\)', '', p) out.append(p); i = e out.append(t[i:]) return ''.join(out) def build_generic(ref, value, libname, x, y, rot, dims, padnets, attr='smd', tmpl=None): """fabricate a footprint from v7 dims using a same-class board template for syntax""" tmpl = tmpl or (T_C if attr == 'smd' else T_B2B) t = tmpl['blk'] head = re.match(r'(\(footprint\s+"[^"]+"\s*(?:\(version[^)]*\))?\s*(?:\(generator[^)]*\))?\s*(?:\(generator_version[^)]*\))?\s*\(layer "F\.Cu"\))', t) # properties section: reuse template's property blocks (Reference/Value/Footprint/Datasheet/Description) + attr + path props = [] for pm in re.finditer(r'\(property "([^"]+)"\s+"[^"]*"', t): a = pm.start(); e = endof(t, a); props.append((pm.group(1), t[a:e])) prop_txt = [] for name, ptxt in props: if name == 'Reference': ptxt = re.sub(r'"Reference"\s+"[^"]+"', f'"Reference" "{ref}"', ptxt, count=1) elif name == 'Value': ptxt = re.sub(r'"Value"\s+"[^"]*"', f'"Value" "{value}"', ptxt, count=1) elif name == 'Footprint': ptxt = re.sub(r'"Footprint"\s+"[^"]*"', f'"Footprint" "{libname}"', ptxt, count=1) ptxt = re.sub(r'\(uuid "[^"]*"\)', lambda m: f'(uuid "{NU()}")', ptxt) prop_txt.append(ptxt) attrm = re.search(r'\(attr [^)]*\)', t) attr_txt = attrm.group(0) if attrm else f'(attr {attr})' if attr == 'smd' and 'smd' not in attr_txt: attr_txt = '(attr smd)' su = sch_uuid.get(ref) path_txt = f'(path "/{su}")' if su else '' sheet = re.search(r'\(sheetfile "[^"]*"\)', t) sheet_txt = (re.search(r'\(sheetname "[^"]*"\)', t).group(0) + '\n\t\t' + sheet.group(0)) if sheet else '' pads = [] for p in dims['pads']: ang = (p['rot'] + rot) % 360 angtxt = f' {ang:g}' if ang else '' drill = f'\n\t\t\t(drill {p["drill"]:g})' if p['drill'] else '' rr = f'\n\t\t\t(roundrect_rratio {p["rratio"]})' if p['rratio'] else '' nettxt = f'\n\t\t\t{N(padnets[p["num"]])}' if p['num'] in padnets else '' layers = p['layers'].replace('*.Cu *.Mask', '"*.Cu" "*.Mask"') if '"' not in p['layers'] else p['layers'] pads.append(f'''\t\t(pad "{p['num']}" {p['kind']} {p['shape']} \t\t\t(at {p['x']:g} {p['y']:g}{angtxt}) \t\t\t(size {p['sx']:g} {p['sy']:g}){drill} \t\t\t(layers {layers}){rr}{nettxt} \t\t\t(uuid "{NU()}") \t\t)''') crt = dims['crt'] crt_txt = '' if crt: crt_txt = f'''\t\t(fp_rect \t\t\t(start {crt[0]:g} {crt[1]:g}) \t\t\t(end {crt[2]:g} {crt[3]:g}) \t\t\t(stroke \t\t\t\t(width 0.05) \t\t\t\t(type default) \t\t\t) \t\t\t(fill no) \t\t\t(layer "F.CrtYd") \t\t\t(uuid "{NU()}") \t\t)''' nl = '\n\t' return f'''(footprint "{libname}" \t\t(layer "F.Cu") \t\t(uuid "{NU()}") \t\t(at {x:g} {y:g}{(" %g"%rot) if rot else ""}) \t\t{nl.join(prop_txt)} \t\t{path_txt} \t\t{sheet_txt} \t\t{attr_txt} {crt_txt} {chr(10).join(pads)} \t)''' INS = [] report = collections.defaultdict(list) # ---------------- deletions ---------------- del_spans = [by_ref(r)['span'] for r in ('J1', 'J2', 'Rts1', 'Rtp1', 'P6')] report['removed'] = ['J1', 'J2', 'Rts1', 'Rtp1', 'P6(1x04)'] # ---------------- P6 swap (B2B clone at old position) ---------------- p6nets = {p['num']: p['net'] for p in pads_of(P6old['blk']) if p['net']} INS.append(retarget(T_B2B, 'P6', 'FAN 2-pin', 'Connector_JST:JST_XH_B2B-XH-A_1x02_P2.50mm_Vertical', P6old['x'], P6old['y'], P6old['rot'], {'1': p6nets.get('1', '5V_FAN'), '2': p6nets.get('2', 'FAN_RET')})) b = fp_bbox(dict(blk=INS[-1], x=P6old['x'], y=P6old['y'], rot=P6old['rot'])) occ.append(('P6new', b)) report['placed'].append(('P6', P6old['x'], P6old['y'], 'B2B swap in place')) # ---------------- relocate W5500-era passives out of the U16 zone ---------------- RELOC = ['C102', 'C90', 'C91', 'C96', 'R96', 'R97'] occ[:] = [(r, bb) for r, bb in occ if r not in RELOC] # ---------------- U16 ---------------- w = LQFP64['crt'][2]-LQFP64['crt'][0]; h = LQFP64['crt'][3]-LQFP64['crt'][1] ux, uy = place(w+0.4, h+0.4, 94.0, 71.0, fixed=True, label='U16') for rref in RELOC: rf = by_ref(rref) rbb = fp_bbox(rf); rw, rh = rbb[2]-rbb[0], rbb[3]-rbb[1] nx, ny = place(rw+0.2, rh+0.2, 96.0, 57.0, label=rref) del_spans.append(rf['span']) INS.append(re.sub(r'\(at [\-0-9. ]+\)', f'(at {nx:g} {ny:g}{(" %g"%rf["rot"]) if rf["rot"] else ""})', rf['blk'], count=1)) report['placed'].append((rref, nx, ny, 'relocated (was in U16 zone)')) MCU_NETS = {'1':'3V3','2':'LED_C13','3':'LSE_IN','4':'LSE_OUT','5':'HSE_IN','6':'HSE_OUT', '7':'NRST','8':'ETH_INT','12':'GND','13':'VDDA','14':'BAT_SENSE','15':'PWR_CTRL','16':'GNSS_TX', '17':'GNSS_RX','18':'GND','19':'3V3','20':'5V_SENSE','21':'IGN_SENSE','22':'FAN_PWM','26':'EXT_LED', '27':'NTC_TEMP','28':'BOOT1_STRAP','30':'VCAP1','31':'GND','32':'3V3','33':'ETH_CS','34':'ETH_SCK', '35':'ETH_MISO','36':'ETH_MOSI','41':'CHRG_EN','42':'USART1_TX','43':'USART1_RX','44':'DFU_DM', '45':'DFU_DP','46':'SWDIO','47':'GND','48':'3V3','49':'SWCLK','50':'ETH_RST','55':'CHRG_STAT', '56':'CHRG_FAULT','57':'BYP_CTRL','58':'I2C_SCL','59':'I2C_SDA','60':'BOOT0','61':'SER_IRQ', '63':'GND','64':'3V3'} for extra in ['BAT_SENSE','PWR_CTRL','GNSS_TX','GNSS_RX','5V_SENSE','IGN_SENSE','FAN_PWM','EXT_LED', 'NTC_TEMP','ETH_CS','ETH_SCK','ETH_MISO','ETH_MOSI','CHRG_EN','USART1_TX','USART1_RX', 'SER_IRQ','CHRG_STAT','CHRG_FAULT','BYP_CTRL','I2C_SCL','I2C_SDA','ETH_RST']: assert resolve(extra), f'net {extra} not in pcb' INS.append(build_generic('U16', 'STM32F411RET6', 'Package_QFP:LQFP-64_10x10mm_P0.5mm', ux, uy, 180, LQFP64, MCU_NETS, tmpl=T_LQFP)) report['placed'].append(('U16', ux, uy, 'rot180')) def small(ref, value, lib, dims, n1, n2, ax, ay, tmpl=None, rot=0): if dims and dims['crt']: ww = dims['crt'][2]-dims['crt'][0]; hh = dims['crt'][3]-dims['crt'][1] elif tmpl is not None: tb = fp_bbox(dict(blk=tmpl['blk'], x=0, y=0, rot=tmpl['rot'])) ww, hh = tb[2]-tb[0]+0.4, tb[3]-tb[1]+0.4 else: ww, hh = 2.4, 1.4 if rot in (90, 270): ww, hh = hh, ww cx, cy = place(ww+0.2, hh+0.2, ax, ay, label=ref) padnets = {'1': n1, '2': n2} if dims: INS.append(build_generic(ref, value, lib, cx, cy, rot, dims, padnets, tmpl=tmpl)) else: INS.append(retarget(tmpl, ref, value, lib, cx, cy, rot, padnets)) report['placed'].append((ref, cx, cy, value)) return cx, cy R_LIB = 'Resistor_SMD:R_0603_1608Metric'; C_LIB = 'Capacitor_SMD:C_0603_1608Metric' # clone 0603 R/C via retarget (same lib) — dims=None path def r0603(ref, val, n1, n2, ax, ay): return small(ref, val, R_LIB, None, n1, n2, ax, ay, tmpl=T_R) def c0603(ref, val, n1, n2, ax, ay): return small(ref, val, C_LIB, None, n1, n2, ax, ay, tmpl=T_C) # decoupling ring around U16 c0603('C105', '100nF', '3V3', 'GND', ux-7.5, uy-7.5) c0603('C106', '100nF', '3V3', 'GND', ux+7.5, uy-7.5) c0603('C107', '100nF', '3V3', 'GND', ux-7.5, uy+7.5) c0603('C108', '100nF', '3V3', 'GND', ux+7.5, uy+7.5) c0603('C110', '4.7uF', '3V3', 'GND', ux, uy-8.2) c0603('C111', '4.7uF', 'VCAP1', 'GND', ux, uy+8.2) c0603('C104', '100nF', 'NRST', 'GND', ux-8.2, uy-5) r0603('R99', '10k', 'BOOT1_STRAP', 'GND', ux-8.2, uy+3) # VDDA filter south r0603('R104', '10R', '3V3', 'VDDA', ux-3, uy+8.9) c0603('C109', '100nF', 'VDDA', 'GND', ux+3, uy+8.9) c0603('C112', '1uF', 'VDDA', 'GND', ux+5.8, uy+8.9) # crystals east of U16 small('Y3', '25MHz', T_HC49['lib'], None, 'HSE_IN', 'HSE_OUT', ux+11, uy+3, tmpl=T_HC49) c0603('C113', '15pF', 'HSE_IN', 'GND', ux+11, uy+6.5) c0603('C114', '15pF', 'HSE_OUT', 'GND', ux+14, uy+6.5) small('Y4', '32.768kHz', 'Crystal:Crystal_SMD_3215-2Pin_3.2x1.5mm', X3215, 'LSE_IN', 'LSE_OUT', ux+11, uy-3) c0603('C115', '12pF', 'LSE_IN', 'GND', ux+14, uy-3) c0603('C116', '12pF', 'LSE_OUT', 'GND', ux+14, uy-0.5) # BOOT0 pulldown near MCU r0603('R98', '10k', 'BOOT0', 'GND', ux+8.2, uy+3) # J11 USB-C at left edge (fixed) — clone J9, rot per convention: mouth (-x) for rref in ['C83']: rf = by_ref(rref) occ[:] = [(r, bb) for r, bb in occ if r != rref] rbb = fp_bbox(rf); rw, rh = rbb[2]-rbb[0], rbb[3]-rbb[1] nx, ny = place(rw+0.2, rh+0.2, 79.0, 86.0, label=rref) del_spans.append(rf['span']) INS.append(re.sub(r'\(at [\-0-9. ]+\)', f'(at {nx:g} {ny:g}{(" %g"%rf["rot"]) if rf["rot"] else ""})', rf['blk'], count=1)) report['placed'].append((rref, nx, ny, 'relocated (was in J11 zone)')) mouth_rot = 90 if ROTSGN == 1 else 270 jx, jy = 71.11, 92.0 jblk = retarget(T_USB, 'J11', 'USB-C DFU', T_USB['lib'], jx, jy, mouth_rot, {'A1':'GND','B1':'GND','A12':'GND','B12':'GND', 'A4':'DFU_VBUS','A9':'DFU_VBUS','B4':'DFU_VBUS','B9':'DFU_VBUS', 'A5':'DFU_CC1','B5':'DFU_CC2','A6':'DFU_DP','B6':'DFU_DP', 'A7':'DFU_DM','B7':'DFU_DM','A8':'','B8':'','SH':'GND'}) bb = fp_bbox(dict(blk=jblk, x=jx, y=jy, rot=mouth_rot)) assert clear(bb, 0.25), f'J11 blocked at {bb} by {colliders(bb, 0.25)}' occ.append(('J11', bb)); INS.append(jblk) report['placed'].append(('J11', jx, jy, f'rot{mouth_rot} mouth-west')) # CC + TVS east of J11 r0603('R100', '5.1k', 'DFU_CC1', 'GND', 79.5, 89) r0603('R101', '5.1k', 'DFU_CC2', 'GND', 79.5, 91.5) small('D14', 'PESD5V0S1BA', 'Diode_SMD:D_SOD-323', SOD323, 'DFU_DP', 'GND', 79.5, 94) small('D15', 'PESD5V0S1BA', 'Diode_SMD:D_SOD-323', SOD323, 'DFU_DM', 'GND', 79.5, 96.5) small('D16', 'PESD5V0S1BA', 'Diode_SMD:D_SOD-323', SOD323, 'DFU_VBUS', 'GND', 79.5, 99) # LDO cluster (under shield, SMD) small('D12', 'SS34', T_SMA['lib'], None, 'LDO_IN', 'SYS5V', 84, 101, tmpl=T_SMA) small('D13', 'SS34', T_SMA['lib'], None, 'LDO_IN', 'DFU_VBUS', 84, 104.5, tmpl=T_SMA) sx_, sy_ = place((SOT223['crt'][2]-SOT223['crt'][0])+0.2, (SOT223['crt'][3]-SOT223['crt'][1])+0.2, 91, 102.5, label='U17') INS.append(build_generic('U17', 'AMS1117-3.3', 'Package_TO_SOT_SMD:SOT-223-3_TabPin2', sx_, sy_, 0, SOT223, {'1':'GND','2':'3V3','3':'LDO_IN','4':'3V3'})) report['placed'].append(('U17', sx_, sy_, 'AMS1117')) ctmpl = T_C0805 or T_C small('C117', '10uF', 'Capacitor_SMD:C_0805_2012Metric' if T_C0805 else C_LIB, None, 'LDO_IN', 'GND', 96.5, 100, tmpl=ctmpl) small('C118', '22uF/16V', 'Capacitor_SMD:C_0805_2012Metric' if T_C0805 else C_LIB, None, '3V3', 'GND', 96.5, 103, tmpl=ctmpl) # LEDs + resistors near USB (visible, not under shield) small('D17', 'LED C13 (blue)', 'LED_SMD:LED_0603_1608Metric', LED063, 'LED_C13', 'LED_C13_A', 64.5, 84) small('D18', 'LED PWR (green)', 'LED_SMD:LED_0603_1608Metric', LED063, 'GND', 'LED_PWR_A', 64.5, 86.6) r0603('R102', '1k', '3V3', 'LED_C13_A', 67.3, 84) r0603('R103', '1k', '3V3', 'LED_PWR_A', 67.3, 86.6) # SW2 BOOT0 next to SW1 swp = pads_of(T_SW['blk']); swnets = {p['num'] for p in swp} sw_nets = {} for p in swp: sw_nets[p['num']] = 'BOOT0' if p['net'] and 'RESET' not in (p['net'] or '') else None # simple: SW_Push pads 1/2 -> 3V3/BOOT0 (both pads of each side share) padmap = {} for p in swp: padmap[p['num']] = '3V3' if p['num'] in ('1', '3') else 'BOOT0' sxw, syw = 191.0, 133.3 blk = retarget(T_SW, 'SW2', 'BOOT0', T_SW['lib'], sxw, syw, T_SW['rot'], padmap) bb = fp_bbox(dict(blk=blk, x=sxw, y=syw, rot=T_SW['rot'])) if not clear(bb, 0.25): placed = False for ax, ay in [(66, 81), (203, 127.5), (155.5, 133), (189, 126), (127, 60)]: try: sxw, syw = place(bb[2]-bb[0]+0.4, bb[3]-bb[1]+0.4, ax, ay, label='SW2') placed = True; break except RuntimeError: continue assert placed, 'SW2: no home found' blk = retarget(T_SW, 'SW2', 'BOOT0', T_SW['lib'], sxw, syw, T_SW['rot'], padmap) else: occ.append(('SW2', bb)) INS.append(blk); report['placed'].append(('SW2', sxw, syw, 'BOOT0 button')) # SWD test points row (north strip) for i, (ref, net) in enumerate([('TP3','SWDIO'),('TP4','SWCLK'),('TP5','NRST'),('TP6','GND'),('TP7','3V3')]): txp, typ = place(2.2, 2.2, 116+i*2.8, 57.5, label=ref) INS.append(retarget(T_TP, ref, net, T_TP['lib'], txp, typ, 0, {'1': net})) report['placed'].append((ref, txp, typ, net)) # Y1 / Y2 moves: rewrite (at) in their blocks Y1f = by_ref('Y1'); Y2f = by_ref('Y2') y1x, y1y = place(12.4, 6.0, 104.5, 56.5, label='Y1') y2x, y2y = place(12.4, 6.0, 80.0, 106.5, label='Y2') def move_block(f, nx, ny): nb = re.sub(r'\(at [\-0-9. ]+\)', f'(at {nx:g} {ny:g}{(" %g"%f["rot"]) if f["rot"] else ""})', f['blk'], count=1) return nb del_spans.append(Y1f['span']); INS.append(move_block(Y1f, y1x, y1y)) del_spans.append(Y2f['span']); INS.append(move_block(Y2f, y2x, y2y)) report['placed'].append(('Y1', y1x, y1y, 'moved east')); report['placed'].append(('Y2', y2x, y2y, 'moved to U10')) # Block Q holes: 4 shield (fixed) + 2 XBee mh_tmpl = T_MH['blk'] def add_hole(ref, x, y, drill): t = mh_tmpl t = re.sub(r'\(property "Reference"\s+"[^"]+"', f'(property "Reference" "{ref}"', t, count=1) t = re.sub(r'\(property "Value"\s+"[^"]*"', f'(property "Value" "MountingHole_M2.5"', t, count=1) t = re.sub(r'\(at [\-0-9. ]+\)', f'(at {x:g} {y:g})', t, count=1) t = re.sub(r'\(drill [0-9.]+\)', f'(drill {drill:g})', t) t = re.sub(r'\(size [0-9.]+ [0-9.]+\)', f'(size {drill:g} {drill:g})', t) t = re.sub(r'\(uuid "[^"]*"\)', lambda m: f'(uuid "{NU()}")', t) bb2 = (x-drill/2-0.5, y-drill/2-0.5, x+drill/2+0.5, y+drill/2+0.5) ok = clear(bb2, 0.25) occ.append((ref, bb2)); INS.append(t) report['holes'].append((ref, x, y, drill, 'CLEAR' if ok else '⚠ TIGHT — review')) for i, (hx, hy) in enumerate(shield_holes): add_hole(f'MH{7+i}', hx, hy, 2.75) add_hole('MH11', 178.0, 60.5, 2.75) add_hole('MH12', 185.35, 91.5, 2.75) # ---------------- title date ---------------- text_patches = [] m = re.search(r'\(title_block.*?\(date "([^"]*)"\)', src, re.S) if m: a = m.start(1) - 7; b = m.end(1) + 2 text_patches.append((m.start(1), m.end(1), '2026-08-12')) # ---------------- splice ---------------- new = src ops = [(a, b, '') for a, b in del_spans] + [(a, b, t) for a, b, t in text_patches] ops.sort(key=lambda o: -o[0]) spansorted = sorted([(a, b) for a, b, _ in ops]) for (a1, b1), (a2, b2) in zip(spansorted, spansorted[1:]): assert b1 <= a2, 'overlap' for a, b, t in ops: if t == '': a0 = a while a0 > 0 and new[a0-1] in ' \t\n': a0 -= 1 new = new[:a0] + '\n\t' + new[b:] else: new = new[:a] + t + new[b:] # KiCad 10: no numbered net table — nets exist via pad references only # insert footprints before final paren end = new.rfind(')') new = new[:end] + '\t' + '\n\t'.join(INS) + '\n' + new[end:] open(PCB, 'w', encoding='utf-8').write(new) print(f'WROTE {PCB}: {len(src)} -> {len(new)} bytes; balance={new.count("(")==new.count(")")}') print(f'new nets: {addnets}') for k in ('removed', 'placed', 'holes'): print(f'-- {k}:') for item in report[k]: print(' ', item)