"""Generate the scoped four-wire prototype revision; retain a pre-change snapshot. Only P6 and the unused U20.IO5 connection on P3 are changed. Existing migration helpers are deliberately not used to regenerate the rest of the project. """ import argparse import json import math from pathlib import Path import re import shutil import uuid from schematic_maintenance import ROOT, parse, child, children, properties LIB = 'PhoneRail-Fan' KICAD = Path('D:/KiCad/share/kicad') PAGE = ROOT / 'PhoneRail-Board1/P6-FAN-EDL.kicad_sch' MARKER = 'FOUR-WIRE FAN PROTOTYPE R1' def uid(key): return str(uuid.uuid5(uuid.NAMESPACE_URL, 'phonerail/fan-r1/' + key)) def quote(value): return json.dumps(str(value), ensure_ascii=False) def symbol_from(path, name, new_name): source = path.read_text(encoding='utf-8') node = next(n for n in children(parse(source), 'symbol') if n[1] == name) assert child(node, 'extends') is None raw = source[node.start:node.end] return raw.replace('"' + name, '"' + new_name) def prop(name, value, x, y, hidden=False): return (f'(property {quote(name)} {quote(value)} (at {x:g} {y:g} 0) ' f'(effects (font (size 1.27 1.27)) {"(hide yes)" if hidden else ""}))') def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument('--apply', action='store_true') args = ap.parse_args() old = PAGE.read_text(encoding='utf-8') if MARKER in old or 'SANYO FAN SELECTION R2' in old: print('Already revised; generation skipped. Edit the current schematic directly.') return assert 'LEGACY THREE-PIN CIRCUIT' in old root = parse(old) original = {properties(n)['Reference'][2]: n for n in children(root, 'symbol')} board = ROOT / 'PhoneRail-Board1' top_id = child(parse((board / 'PhoneRail-Board1.kicad_sch').read_text(encoding='utf-8')), 'uuid')[1] page_id = child(root, 'uuid')[1] sheet_path = child(child(child(original['Q6'], 'instances'), 'project'), 'path')[1] assert sheet_path.startswith('/' + top_id) shared = ROOT / 'lib/PhoneRail.kicad_sym' definitions = { 'R': symbol_from(shared, '0402WGF1002TCE', 'R'), 'NMOS': symbol_from(shared, 'AO3400A', 'NMOS'), 'PMOS': symbol_from(KICAD / 'symbols/Transistor_FET.kicad_sym', 'TP0610T', 'PMOS'), 'Zener': symbol_from(KICAD / 'symbols/Device.kicad_sym', 'D_Zener', 'Zener'), } # Gate/input and channel/passive follow KiCad's standard discrete MOS symbols. raw = definitions['NMOS'] for unit in children(parse(raw), 'symbol'): for pin in sorted(children(unit, 'pin'), key=lambda p: p.start, reverse=True): kind = 'input' if child(pin, 'number')[1] == '1' else 'passive' raw = raw[:pin.start] + raw[pin.start:pin.end].replace('pin unspecified', 'pin ' + kind) + raw[pin.end:] definitions['NMOS'] = raw for ref, key in [('D2', 'Schottky'), ('C738', 'C')]: libid = child(original[ref], 'lib_id')[1].split(':')[1] definitions[key] = symbol_from(shared, libid, key) definitions['FanHeader'] = '''(symbol "FanHeader" (pin_names (offset 1.016)) (in_bom yes) (on_board yes) (property "Reference" "J" (at 0 5.08 0) (effects (font (size 1.27 1.27)))) (property "Value" "47053-1000" (at 0 -10.16 0) (effects (font (size 1.27 1.27)))) (property "Footprint" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes))) (property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes))) (symbol "FanHeader_0_1" (rectangle (start 0 2.54) (end 17.78 -10.16) (stroke (width 0.254) (type default)) (fill (type background)))) (symbol "FanHeader_1_1" ''' + ''.join(f'''(pin passive line (at -5.08 {-2.54*(i-1):g} 0) (length 5.08) (name "{name}" (effects (font (size 1.27 1.27)))) (number "{i}" (effects (font (size 1.27 1.27)))))''' for i, name in enumerate(['GND', '+12V', 'TACH', 'PWM'], 1)) + '))' # These are generic drawing primitives. Do not retain the source part's # voltage rating, datasheet or footprint as inherited library metadata. for key, raw in definitions.items(): edits = [] for name, value in [('Value', key), ('Footprint', ''), ('Datasheet', ''), ('Description', 'Four-wire prototype: see instance MPN and FAN_CIRCUIT.md')]: p = properties(parse(raw)).get(name) if p: edits.append((p.start, p.end, prop(name, value, 0, 0, name != 'Value'))) for start, end, replacement in sorted(edits, reverse=True): raw = raw[:start] + replacement + raw[end:] definitions[key] = raw parts, pins, items = {}, {}, [] def add(ref, key, x, y, value, footprint, mpn, angle=0, manufacturer='', datasheet='', lcsc=''): assert ref not in parts sx = child(original[ref], 'uuid')[1] if ref in original else uid(ref) parts[ref] = sx node = parse(definitions[key]) coords = {} for unit in children(node, 'symbol'): for pin in children(unit, 'pin'): px, py = map(float, child(pin, 'at')[1:3]) t = math.radians(angle) coords[child(pin, 'number')[1]] = (round(x + px*math.cos(t) - py*math.sin(t), 4), round(y - px*math.sin(t) - py*math.cos(t), 4)) pins[ref] = coords if key in {'NMOS', 'PMOS'}: tx, ty = x + (10.16 if angle == 0 else -12.7), y else: tx, ty = x, y - (5.08 if angle == 0 else 8.89) metadata = {'Footprint': footprint, 'MPN': mpn, 'Manufacturer': manufacturer, 'Datasheet': datasheet, 'LCSC': lcsc} items.append(f'(symbol (lib_id "{LIB}:{key}") (at {x:g} {y:g} {angle}) (unit 1) ' f'(in_bom yes) (on_board yes) (dnp no) (uuid "{sx}") ' + prop('Reference', ref, tx, ty - 2.54) + prop('Value', value, tx, ty) + ''.join(prop(k, v, x, y, True) for k, v in metadata.items()) + ''.join(f'(pin "{p}" (uuid "{uid(ref + "." + p)}"))' for p in coords) + f'(instances (project "PhoneRail-Board1" (path "{sheet_path}" (reference "{ref}") (unit 1)))))') def wire(a, b): assert a != b assert a[0] == b[0] or a[1] == b[1], (a, b) items.append(f'(wire (pts (xy {a[0]:g} {a[1]:g}) (xy {b[0]:g} {b[1]:g})) ' f'(stroke (width 0) (type default)) (uuid "{uid(str((a,b)))}"))') def join(*points): for a, b in zip(points, points[1:]): wire(a, b) def label(net, pos, global_net=False): typ = 'global_label' if global_net or net in {'+12V', '+3V3', 'GND', 'FAN_EN', 'FAN_PWM', 'FAN_TACH_IN', 'FAN_TACH_OUT'} else 'label' extra = '(shape bidirectional)' if typ == 'global_label' else '' items.append(f'({typ} {quote(net)} {extra} (at {pos[0]:g} {pos[1]:g} 0) ' f'(effects (font (size 1.27 1.27)) (justify left bottom)) (uuid "{uid(net+str(pos))}"))') def pin(ref, number): return pins[ref][str(number)] def terminal(ref, number, net, dx=0, dy=5.08): p = pin(ref, number) q = (round(p[0]+dx, 4), round(p[1]+dy, 4)) wire(p, q) label(net, q) def resistor(ref, value, x, y, angle=0): code = {1000: '1001', 10000: '1002', 100000: '1003'}[value] add(ref, 'R', x, y, f'{value//1000}k', 'PhoneRail:R0402', '0402WGF'+code+'TCE', angle, 'UNI-ROYAL') def mos(ref, x, y, pm=False): mpn = 'AO3401A' if pm else 'AO3400A' add(ref, 'PMOS' if pm else 'NMOS', x, y, mpn, 'PhoneRail:SOT-23-3_L2.9-W1.3-P1.90-LS2.4-BR', mpn, 180 if pm else 0, 'Alpha & Omega Semiconductor', f'https://www.aosmd.com/sites/default/files/res/datasheets/{mpn}.pdf') def note(x, y, text, size=1.27): items.append(f'(text {quote(text)} (at {x:g} {y:g} 0) ' f'(effects (font (size {size} {size})) (justify left bottom)) (uuid "{uid(text)}"))') note(15.24, 15.24, MARKER, 2.0) note(15.24, 22.86, '2x NF-A6x25 PWM / J26 intake / J27 exhaust. Vendor limits and prototype tests OPEN.') note(15.24, 30.48, 'HIGH-SIDE POWER: GPIO5 high = ON; hardware reset = OFF', 1.5) mos('Q7', 76.2, 55.88, True) terminal('Q7', 2, '+12V', dy=-15.24) terminal('Q7', 3, 'FAN_12V_SW') resistor('R740', 100000, 109.22, 40.64, 90) terminal('R740', 2, '+12V', dy=-5.08) join(pin('Q7', 1), (109.22, 55.88), pin('R740', 1)) add('DZ3', 'Zener', 137.16, 40.64, 'BZT52C8V2-7-F', f'{LIB}:D_SOD-123', 'BZT52C8V2-7-F', 270, 'Diodes Incorporated', 'https://www.diodes.com/assets/Datasheets/ds18004.pdf') terminal('DZ3', 1, '+12V', dy=-5.08) join(pin('DZ3', 2), (137.16, 55.88), (109.22, 55.88)) resistor('R741', 1000, 109.22, 76.2, 90) wire((109.22, 55.88), pin('R741', 2)) mos('Q8', 106.68, 101.6) wire(pin('R741', 1), pin('Q8', 3)) terminal('Q8', 2, 'GND') resistor('R742', 1000, 71.12, 101.6) terminal('R742', 1, 'FAN_EN', dx=-7.62, dy=0) join(pin('R742', 2), (91.44, 101.6), pin('Q8', 1)) resistor('R743', 100000, 91.44, 119.38, 90) wire((91.44, 101.6), pin('R743', 2)) terminal('R743', 1, 'GND') for ref, key, x, y in [('D2', 'Schottky', 27.94, 76.2), ('C738', 'C', 27.94, 114.3)]: p = properties(original[ref]) add(ref, key, x, y, p['Value'][2], p['Footprint'][2], p['MPN'][2], 90, p['Manufacturer'][2], p['Datasheet'][2], p['LCSC'][2]) # Imported diode: pin 1 cathode. Imported capacitor: pin 1 left. terminal(ref, 1, 'FAN_12V_SW', dx=-7.62, dy=0) terminal(ref, 2, 'GND', dx=7.62, dy=0) note(15.24, 142.24, 'Q7 body diode blocks +12V -> fan when OFF. No reverse isolation/current limiting.') note(15.24, 149.86, 'DZ3 clamps |Vgs|; validate clamp tolerance, startup SOA and short-circuit protection.') note(15.24, 162.56, 'OPEN-DRAIN PWM: GPIO18 high pulls fan pin 4 LOW', 1.5) mos('Q6', 106.68, 193.04) resistor('R733', 1000, 71.12, 193.04) terminal('R733', 1, 'FAN_PWM', dx=-7.62, dy=0) join(pin('R733', 2), (91.44, 193.04), pin('Q6', 1)) resistor('R734', 100000, 91.44, 213.36, 90) wire((91.44, 193.04), pin('R734', 2)) terminal('R734', 1, 'GND') terminal('Q6', 2, 'GND') terminal('Q6', 3, 'FAN_PWM_OD', dy=-7.62) note(15.24, 238.76, '25kHz target; invert MCU duty: D_gpio = 1 - D_fan. Confirm vendor limits.') note(15.24, 246.38, 'Stop/reset: FAN_EN=0. PWM alone does not guarantee a stopped fan.') note(15.24, 254, 'Firmware: two counters, startup grace, per-fan stall fault and commanded-OFF mask.') note(15.24, 266.7, 'PROTOTYPE ONLY: see FAN_CIRCUIT.md. Not released for PCB fabrication.', 1.5) for ref, x, raw in [('J26', 284.48, 'FAN_IN_TACH_RAW'), ('J27', 378.46, 'FAN_OUT_TACH_RAW')]: add(ref, 'FanHeader', x, 48.26, '47053-1000', f'{LIB}:FanPinHeader_1x04_P2.54mm_Vertical', '470531000', manufacturer='Molex') for p, net in enumerate(['GND', 'FAN_12V_SW', raw, 'FAN_PWM_OD'], 1): terminal(ref, p, net, dx=-7.62, dy=0) note(223.52, 76.2, 'Keyed PC fan header: 1 GND / 2 +12V / 3 TACH / 4 PWM.') note(223.52, 83.82, 'Confirm header drawing, key orientation and cable fit before layout.') note(223.52, 96.52, 'INDEPENDENT TACH: switched pullups avoid a 3V3 path into an unpowered fan', 1.5) for idx, y in enumerate([132.08, 215.9]): ref = ['Q9', 'Q10'][idx] raw = ['FAN_IN_TACH_RAW', 'FAN_OUT_TACH_RAW'][idx] output = ['FAN_TACH_IN', 'FAN_TACH_OUT'][idx] a, b, c, d = [f'R{744 + idx*4 + j}' for j in range(4)] mos(ref, 332.74, y) resistor(a, 10000, 246.38, y-17.78, 90) terminal(a, 2, 'FAN_12V_SW', dy=-5.08) label(raw, (246.38, y)) wire(pin(a, 1), (246.38, y)) resistor(b, 10000, 279.4, y) wire((246.38, y), pin(b, 1)) join(pin(b, 2), (307.34, y), pin(ref, 1)) resistor(c, 10000, 307.34, y+17.78, 90) wire((307.34, y), pin(c, 2)) terminal(c, 1, 'GND') terminal(ref, 2, 'GND') resistor(d, 10000, 363.22, y-17.78, 90) terminal(d, 2, '+3V3', dy=-5.08) join(pin(ref, 3), (335.28, y-7.62), (363.22, y-7.62), pin(d, 1)) terminal(d, 1, output, dx=7.62, dy=0) note(223.52, y+35.56, f'{output}: inverted RAW; MCU GPIO{16 if idx == 0 else 4}. Count one edge per pulse.') note(223.52, 264.16, 'RAW high: Vgate ~ Vfan/3. RAW low: Vgate ~ Vtach_low/2.') note(223.52, 271.78, 'Electrical limits, hot-plug/ESD, power sequencing and thermal validation remain open.') # Explicit junctions for every drawn T connection, independent of label semantics. for x, y in [(109.22,55.88), (91.44,101.6), (91.44,193.04), (307.34,132.08), (307.34,215.9), (363.22,119.38), (363.22,203.2)]: items.append(f'(junction (at {x:g} {y:g}) (diameter 0) (color 0 0 0 0) (uuid "{uid("junction"+str((x,y)))}"))') embedded = '\n'.join(raw.replace('(symbol "'+key+'"', '(symbol "'+LIB+':'+key+'"', 1) for key, raw in definitions.items()) page = (f'(kicad_sch (version 20250610) (generator "eeschema") (uuid "{page_id}") (paper "A3") ' '(title_block (title "P6-FAN-EDL / Four-wire fan prototype") (rev "FAN-R1") (date "2026-09-07")) ' f'(lib_symbols {embedded})\n' + '\n'.join(items) + '\n(embedded_fonts no))\n') parse(page) existing_refs = set() for path in board.glob('*.kicad_sch'): if path != PAGE: existing_refs.update(properties(n)['Reference'][2] for n in children(parse(path.read_text(encoding='utf-8')), 'symbol')) assert not existing_refs.intersection(parts), existing_refs.intersection(parts) control = board / 'P3-CONTROL.kicad_sch' source = control.read_text(encoding='utf-8') control_root = parse(source) mcu = next(n for n in children(control_root, 'symbol') if properties(n)['Reference'][2] == 'U20') assert child(mcu, 'at')[1:] == ['64.77', '66.04', '0'] io5 = (76.2, 27.94) assert 'FAN_EN' not in source for n in children(control_root, 'wire'): assert io5 not in [tuple(map(float, p[1:])) for p in children(child(n, 'pts'), 'xy')] extra = f'''(wire (pts (xy 76.2 27.94) (xy 91.44 27.94)) (stroke (width 0) (type default)) (uuid "{uid('io5-wire')}")) (global_label "FAN_EN" (shape input) (at 91.44 27.94 0) (effects (font (size 1.27 1.27)) (justify left)) (uuid "{uid('io5-label')}"))''' source = source[:control_root.end-1] + extra + source[control_root.end-1:] outputs = {PAGE: page, control: source, ROOT / f'lib/{LIB}.kicad_sym': '(kicad_symbol_lib (version 20241209) (generator "kicad_symbol_editor")\n' + '\n'.join(definitions.values()) + '\n)\n'} for name, category in [('FanPinHeader_1x04_P2.54mm_Vertical', 'Connector'), ('D_SOD-123', 'Diode_SMD')]: outputs[ROOT / f'lib/{LIB}.pretty/{name}.kicad_mod'] = (KICAD / f'footprints/{category}.pretty/{name}.kicad_mod').read_text(encoding='utf-8') for table, kind, uri in [('sym-lib-table', 'KiCad', f'${{KIPRJMOD}}/../lib/{LIB}.kicad_sym'), ('fp-lib-table', 'KiCad', f'${{KIPRJMOD}}/../lib/{LIB}.pretty')]: path = board / table src = path.read_text(encoding='utf-8') end = parse(src).end - 1 assert LIB not in src outputs[path] = src[:end] + f'(lib (name "{LIB}") (type "{kind}") (uri "{uri}") (options "") (descr "Four-wire prototype additions"))\n' + src[end:] print(f'P6: {len(parts)} parts; Board1 expected total {284 - 7 + len(parts)}') for path in outputs: print(path.relative_to(ROOT)) if not args.apply: return backup = ROOT / 'out/before-four-wire' backup.mkdir(exist_ok=False) for path in [PAGE, control, board/'sym-lib-table', board/'fp-lib-table', ROOT/'out/Board1.xml', ROOT/'out/Board2.xml']: shutil.copy2(path, backup / path.name) for path, content in outputs.items(): path.parent.mkdir(exist_ok=True) path.write_text(content, encoding='utf-8', newline='\n') if __name__ == '__main__': main()