文件
phonerail/kicad/scripts/revise_four_wire_fans.py
T

304 行
16 KiB
Python

"""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()