#!/usr/bin/env python3 """v6 T9 - Block T: on-board vehicle power input 9-36V -> 5V/3A (replaces external Mean Well SD-15B-5, 18-36V 5V/3A). Chain: /24V_IN (P1, existing SMBJ33A D10 at entry) -> F2 2A fuse -> Q23 IRFR5305 reverse-polarity P-FET (D=battery, S=load; R119 100k G->GND, D33 BZX84C15 G->S clamp) -> /VIN_PROT (SMCJ33A D32 + 2x2.2uF/100V + 100uF/50V bulk) -> U25 TPS54360B (60V 3.5A, 600kHz) -> L22 SRP1265A-100M 10uH -> /VEH5V (LTC4040 input; P2 Meanwell jack stays as bench backup - populate one source at a time). Values = TI TPS54360B datasheet 5V/3.5A design example: RT 162k (600kHz), FB 53.6k/10.2k (5.00V), COMP 13k+6.8nF+39pF, boot 100nF, catch B560C, Cout 2x47uF. EN floating = enabled (int. UVLO 4.3V, max cranking ride-through; Vin(min) for 5V reg = 5.56V); optional UVLO divider R124 523k / R125 84.5k fitted DNP. Also REPAIRS the corrupted D_Zener_ANK pin numbers (@@@A1@@/@@@K3@@ -> 1/3) so DzG1 finally connects to its SOT-23 pads, and re-adds the RF / PWR_MODEM netclasses that were lost from the .kicad_pro, plus new PWR_IN class. Prereq: easyeda2kicad --full --lcsc_id C840531 (Bourns SRP1265A-100M) into Drogheda-v6 libs, so the L22 footprint exists. Run with --tbd to skip that check (footprint TBD). Usage: python3 2026-08-31_v6_T9_pwr_input.py [--apply] [--tbd] """ import os, sys, shutil, uuid, re, json, glob KI = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '03-Architecture-Hardware', 'kicad') SCH = os.path.join(KI, 'hbox-watchdog-v6.kicad_sch') DSYM= os.path.join(KI, 'Drogheda-sym.kicad_sym') PRO = os.path.join(KI, 'hbox-watchdog-v6.kicad_pro') APPLY = '--apply' in sys.argv TBD = '--tbd' in sys.argv ROOT = '/561403b3-895e-4153-a7be-3876ae930d43' NC = '' def f(v): v = 0.0 if abs(v) < 1e-9 else v return '%.10g' % v def bal(t): d=0; instr=False; esc=False for ch in t: if esc: esc=False; continue if ch=='\\': esc=True; continue if ch=='"': instr = not instr; continue if instr: continue if ch=='(': d+=1 elif ch==')': d-=1 return d def span_from(text, i): d=0; instr=False; esc=False; j=i while j < len(text): ch=text[j] if esc: esc=False elif ch=='\\': esc=True elif ch=='"': instr = not instr elif not instr: if ch=='(': d+=1 elif ch==')': d-=1 if d==0: return j+1 j+=1 raise ValueError('unbalanced') PIN_RE2= re.compile(r'\(pin \w+ \w+\s*\n\s*\(at ([-\d.]+) ([-\d.]+) (\d+)\)(?:(?!\(pin ).)*?\(number "([^"]+)"', re.S) def libpins(libsrc, name): i = libsrc.find('(symbol "%s"' % name) if i < 0: return None j = libsrc.find('\n (symbol "', i+10) if j < 0: j = libsrc.find('\n\t\t(symbol "', i+10) body = libsrc[i: j if j>0 else len(libsrc)] pins = PIN_RE2.findall(body) return [(num, float(x), float(y)) for x,y,rot,num in pins] sch = open(SCH, encoding='utf-8').read() dsym = open(DSYM, encoding='utf-8').read() # UUID integrity guard: any change to the set of UUIDs already present = abort at write uuids_before = set(re.findall(r'\(uuid "([0-9a-f-]+)"\)', sch)) # ---------- 0a. repair corrupted D_Zener_ANK pin numbers (cache + DzG1 instance) ---------- zfix = sch.count('"@@@A1@@"') + sch.count('"@@@K3@@"') sch = sch.replace('"@@@A1@@"', '"1"').replace('"@@@K3@@"', '"3"') # ---------- 0b. new symbols into Drogheda-sym ---------- def simple_sym(name, desc, fp, pins, w=7.62, h=8.89): s = ' (symbol "%s"\n (exclude_from_sim no)\n (in_bom yes)\n (on_board yes)\n' % name for pn,pv,px,py,hide in [('Reference','U',0,h+3.81,''),('Value',name,0,h+1.27,''), ('Footprint',fp,0,-h-2.54,'\n (hide yes)'), ('Datasheet','~',0,-h-5.08,'\n (hide yes)'), ('Description',desc,0,-h-7.62,'\n (hide yes)')]: s += (' (property "%s" "%s"\n (at %s %s 0)%s\n (effects (font (size 1.27 1.27)))\n )\n' ) % (pn,pv,f(px),f(py),hide) s += (' (symbol "%s_0_1"\n (rectangle\n (start %s %s)\n (end %s %s)\n' ' (stroke (width 0.254) (type default))\n (fill (type background))\n )\n )\n' ' (symbol "%s_1_1"\n') % (name,f(-w),f(h),f(w),f(-h),name) for num,nm,tp,px,py,rot in pins: s += (' (pin %s line\n (at %s %s %d)\n (length 5.08)\n' ' (name "%s"\n (effects (font (size 1.27 1.27)))\n )\n' ' (number "%s"\n (effects (font (size 1.27 1.27)))\n )\n )\n') % (tp,f(px),f(py),rot,nm,num) s += ' )\n )\n' return s TPS_PINS=[('2','VIN','power_in',-12.7,5.08,0),('3','EN','input',-12.7,0,0),('4','RT/CLK','passive',-12.7,-5.08,0), ('1','BOOT','passive',12.7,5.08,180),('8','SW','passive',12.7,2.54,180), ('5','FB','passive',12.7,-2.54,180),('6','COMP','passive',12.7,-5.08,180), ('7','GND','power_in',-2.54,-13.97,90),('9','EP','passive',2.54,-13.97,90)] TPS = simple_sym('TPS54360B','TI 60V 3.5A step-down, SOIC-8-EP (DDA). LCSC C524806. 4.5-60V in, 0.8V ref.', 'Drogheda-v6:SO-8_L4.9-W3.9-P1.27-LS6.0-BL-EP', TPS_PINS) FUSE_PINS=[('1','~','passive',0,8.89,270),('2','~','passive',0,-8.89,90)] FUS = simple_sym('Fuse_CER','Ceramic SMD fuse (one-time). Distinct from PPTC Polyfuse.', 'Fuse:Fuse_1812_4532Metric', FUSE_PINS, w=2.54, h=3.81) added_syms=[] for name, body in (('TPS54360B',TPS),('Fuse_CER',FUS)): if '(symbol "%s"' % name not in dsym: end = dsym.rstrip(); assert end.endswith(')') dsym = end[:-1].rstrip('\n') + '\n' + body + ')\n' added_syms.append(name) # ---------- 1. cache new symbols into schematic lib_symbols ---------- def grab(libsrc, name, prefix): i = libsrc.find(' (symbol "%s"\n' % name) if i < 0: sys.exit('symbol %s not found in source lib' % name) j = libsrc.index('(', i) k = span_from(libsrc, j) return libsrc[j:k].replace('(symbol "%s"' % name, '(symbol "%s:%s"' % (prefix,name), 1) cache_add = '' for nm in ('TPS54360B','Fuse_CER'): if '(symbol "Drogheda-sym:%s"' % nm not in sch: cache_add += '\n\t\t' + grab(dsym, nm, 'Drogheda-sym') m = re.search(r'\n\t\(lib_symbols\n', sch) k = span_from(sch, sch.index('(', m.start())) sch = sch[:k-1] + cache_add + '\n\t' + sch[k-1:] lib_cached = sch.split('(lib_symbols',1)[1] def cachedpins(name): return libpins(lib_cached, name) # ---------- 2. L22 footprint: resolve from Drogheda-v6.pretty ---------- l22_fp = 'Drogheda-v6:IND-SMD_SRP1265A_TBD' hits = [p for p in glob.glob(os.path.join(KI,'Drogheda-v6.pretty','*.kicad_mod')) if re.search(r'1265|SRP|IND-SMD_L13', os.path.basename(p), re.I)] if hits: l22_fp = 'Drogheda-v6:' + os.path.basename(hits[0])[:-len('.kicad_mod')] elif not TBD: sys.exit('L22 footprint not found in Drogheda-v6.pretty - run on the Mac first:\n' ' easyeda2kicad --full --lcsc_id C840531 --output ~/Documents/Claude/Projects/Drogheda/03-Architecture-Hardware/kicad/Drogheda-v6\n' 'or re-run this script with --tbd to use a placeholder footprint.') # ---------- 3. guards: refdes free? nets/labels/wires/pins ---------- inst = {} for b in re.finditer(r'\n\t\(symbol\n(?:(?!\n\t\(symbol\n).)*', sch, re.S): blk=b.group(0) r=re.search(r'\(property "Reference" "([^"]+)"',blk); l=re.search(r'\(lib_id "([^"]+)"',blk); a=re.search(r'\(at ([-\d.]+) ([-\d.]+) (\d+)\)',blk) if r and l and a: inst[r.group(1)]=(l.group(1),float(a.group(1)),float(a.group(2))) NEWREFS = ['F2','Q23','D32','D33','D34','L22','U25','PF19', 'R119','R120','R121','R122','R123','R124','R125', 'C149','C150','C151','C152','C153','C154','C155','C156'] clash = [r for r in NEWREFS if r in inst] if clash: sys.exit('refdes already in use: %s' % clash) def find_labels(text): d={} for m in re.finditer(r'\n\t\((label|global_label) "([^"]+)"\s*\n\s*\(at ([-\d.]+) ([-\d.]+) (\d+)\)', text): d[(round(float(m.group(3)),3), round(float(m.group(4)),3))] = (m.group(2), m.start()+1) return d wire_vts = set() for m in re.finditer(r'\(wire\s*\n\s*\(pts\s*\n((?:\s*\(xy [-\d.]+ [-\d.]+\)\s*\n?)+)', sch): for a,b in re.findall(r'\(xy ([-\d.]+) ([-\d.]+)\)', m.group(1)): wire_vts.add((round(float(a),3), round(float(b),3))) pin_coords = set() for ref,(lb_,X_,Y_) in inst.items(): for num,px,py in (cachedpins(lb_) or []): pin_coords.add((round(X_+px,3), round(Y_-py,3))) # ---------- 4. build additions ---------- add = '' nlab = nnc = 0 newlabels = [] def add_lab(net,x,y,rot,just): global add,nlab add += ('\n\t(label "%s"\n\t\t(at %s %s %d)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.27 1.27)\n' '\t\t\t)\n\t\t\t(justify %s)\n\t\t)\n\t\t(uuid "%s")\n\t)') % (net,f(x),f(y),rot,just,uuid.uuid4()) nlab += 1; newlabels.append((net,round(x,3),round(y,3))) def add_nc(x,y): global add,nnc add += '\n\t(no_connect\n\t\t(at %s %s)\n\t\t(uuid "%s")\n\t)' % (f(x),f(y),uuid.uuid4()) nnc += 1 def mksym(lib_id, ref, val, x, y, fp, npins, lcsc='', dnp=False): props=[('Reference',ref,x-3,y-3,''),('Value',val,x+3,y-3,''),('Footprint',fp,x,y,'\n\t\t\t(hide yes)'), ('Datasheet','~',x,y,'\n\t\t\t(hide yes)'),('Description','',x,y,'\n\t\t\t(hide yes)')] if lcsc: props.append(('LCSC',lcsc,x,y,'\n\t\t\t(hide yes)')) p='' for nm,v,px,py,hide in props: p += ('\t\t(property "%s" "%s"\n\t\t\t(at %s %s 0)%s\n\t\t\t(show_name no)\n\t\t\t(do_not_autoplace no)\n' '\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1.27 1.27)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n') % (nm,v,f(px),f(py),hide) pn=''.join('\t\t(pin "%s"\n\t\t\t(uuid "%s")\n\t\t)\n' % (n,uuid.uuid4()) for n in npins) return ('\n\t(symbol\n\t\t(lib_id "%s")\n\t\t(at %s %s 0)\n\t\t(unit 1)\n\t\t(body_style 1)\n' '\t\t(exclude_from_sim no)\n\t\t(in_bom yes)\n\t\t(on_board yes)\n\t\t(in_pos_files yes)\n' '\t\t(dnp %s)\n\t\t(uuid "%s")\n%s%s\t\t(instances\n\t\t\t(project "hbox-watchdog-v6"\n' '\t\t\t\t(path "%s"\n\t\t\t\t\t(reference "%s")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)' ) % (lib_id,f(x),f(y),'yes' if dnp else 'no',uuid.uuid4(),p,pn,ROOT,ref) def orient(px,py): if abs(px) < 0.01: return 90,'left bottom' return (0,'left bottom') if px>0 else (180,'right bottom') def place(lib, ref, val, x, y, fp, nets, lcsc='', dnp=False, src='cache'): """nets: dict pin-number -> net (or NC).""" pp = cachedpins(lib) if src=='cache' else libpins(dsym, lib.split(':')[1]) if not pp: sys.exit('no pins for %s' % lib) global add add += mksym(lib,ref,val,x,y,fp,[num for num,_,_ in pp],lcsc,dnp=dnp) for num,px,py in pp: rot,just = orient(px,py) n = nets[num] if n == NC: add_nc(x+px,y-py) else: add_lab(n,x+px,y-py,rot,just) # ----- Block T title ----- add += ('\n\t(text "BLOCK T - VEHICLE POWER INPUT 9-36V -> 5V/3A (replaces Mean Well SD-15B-5). ' 'F2+Q23 rev-pol+SMCJ33A -> TPS54360B 600kHz -> VEH5V. P2 = bench backup, one source at a time."\n' '\t\t(exclude_from_sim no)\n\t\t(at 40 322 0)\n\t\t(effects\n\t\t\t(font\n\t\t\t\t(size 1.6 1.6)\n' '\t\t\t)\n\t\t\t(justify left bottom)\n\t\t)\n\t\t(uuid "%s")\n\t)') % uuid.uuid4() # ----- protection front end ----- place('Drogheda-sym:Fuse_CER','F2','2A/63V-slow-1812',55.0,345.0,'Fuse:Fuse_1812_4532Metric', {'1':'24V_IN','2':'VIN_FUSED'},'TBD-2A-1812',src='dsym') place('Device:Q_PMOS_GDS','Q23','IRFR5305',72.0,345.0,'Package_TO_SOT_SMD:TO-252-2', {'1':'Q23_GATE','2':'VIN_FUSED','3':'VIN_PROT'}) place('Device:R','R119','100k',85.0,352.0,'Resistor_SMD:R_0603_1608Metric', {'1':'Q23_GATE','2':'GND'}) place('Device:D_Zener_ANK','D33','BZX84C15',92.0,352.0,'Package_TO_SOT_SMD:SOT-23', {'1':'Q23_GATE','3':'VIN_PROT'}) # repaired ANK symbol: 1=A (gate), 3=K (source) place('Device:D_TVS','D32','SMCJ33A',100.0,352.0,'Diode_SMD:D_SMC', {'1':'VIN_PROT','2':'GND'},'C699039') place('Device:C','C149','2.2uF/100V',108.0,352.0,'Capacitor_SMD:C_1210_3225Metric', {'1':'VIN_PROT','2':'GND'}) place('Device:C','C150','2.2uF/100V',114.0,352.0,'Capacitor_SMD:C_1210_3225Metric', {'1':'VIN_PROT','2':'GND'}) place('Device:C_Polarized','C151','100uF/50V',121.0,352.0,'Capacitor_SMD:CP_Elec_8x10', {'1':'VIN_PROT','2':'GND'}) # ----- buck ----- place('Drogheda-sym:TPS54360B','U25','TPS54360B',145.0,348.0, 'Drogheda-v6:SO-8_L4.9-W3.9-P1.27-LS6.0-BL-EP', {'1':'BOOT_PWRIN','2':'VIN_PROT','3':'EN_PWRIN','4':'RT_PWRIN','5':'FB_PWRIN', '6':'COMP_PWRIN','7':'GND','8':'SW_PWRIN','9':'GND'},'C524806',src='dsym') place('Device:C','C152','100nF',162.0,338.0,'Capacitor_SMD:C_0603_1608Metric', {'1':'BOOT_PWRIN','2':'SW_PWRIN'}) place('Device:D_Schottky','D34','B560C',166.0,352.0,'Diode_SMD:D_SMC', {'1':'SW_PWRIN','2':'GND'},'C85100') # 1=K on SW, 2=A on GND place('Device:L','L22','10uH/SRP1265A',174.0,348.0,l22_fp, {'1':'SW_PWRIN','2':'VEH5V'},'C840531') place('Device:C','C153','47uF/16V',182.0,352.0,'Capacitor_SMD:C_1210_3225Metric', {'1':'VEH5V','2':'GND'}) place('Device:C','C154','47uF/16V',188.0,352.0,'Capacitor_SMD:C_1210_3225Metric', {'1':'VEH5V','2':'GND'}) # ----- control passives ----- place('Device:R','R120','162k/1%',120.0,368.0,'Resistor_SMD:R_0603_1608Metric', {'1':'RT_PWRIN','2':'GND'}) place('Device:R','R124','523k/1%-DNP',128.0,368.0,'Resistor_SMD:R_0603_1608Metric', {'1':'VIN_PROT','2':'EN_PWRIN'},dnp=True) place('Device:R','R125','84.5k/1%-DNP',134.0,368.0,'Resistor_SMD:R_0603_1608Metric', {'1':'EN_PWRIN','2':'GND'},dnp=True) place('Device:R','R123','13k',140.0,368.0,'Resistor_SMD:R_0603_1608Metric', {'1':'COMP_PWRIN','2':'COMP_PWRIN_RC'}) place('Device:C','C155','6.8nF',146.0,368.0,'Capacitor_SMD:C_0402_1005Metric', {'1':'COMP_PWRIN_RC','2':'GND'}) place('Device:C','C156','39pF',152.0,368.0,'Capacitor_SMD:C_0402_1005Metric', {'1':'COMP_PWRIN','2':'GND'}) place('Device:R','R121','53.6k/1%',160.0,368.0,'Resistor_SMD:R_0603_1608Metric', {'1':'VEH5V','2':'FB_PWRIN'}) place('Device:R','R122','10.2k/1%',166.0,368.0,'Resistor_SMD:R_0603_1608Metric', {'1':'FB_PWRIN','2':'GND'}) # ----- power flag on the buck input net ----- pf = cachedpins('power:PWR_FLAG') add += mksym('power:PWR_FLAG','PF19','PWR_FLAG',60.0,332.0,'',[num for num,_,_ in pf]) add_lab('VIN_PROT',60.0+pf[0][1],332.0-pf[0][2],90,'left bottom') # ---------- 5. collision check ---------- existing = set(find_labels(sch).keys()) | wire_vts | pin_coords coll = [(n,x,y) for n,x,y in newlabels if (x,y) in existing] if coll: sys.exit('LABEL COLLISION with existing labels/wires/pins: %s' % coll[:8]) end = sch.rstrip(); assert end.endswith(')') sch = end[:-1].rstrip('\n') + add + '\n)\n' # UUID integrity: all pre-existing uuids still present uuids_after = set(re.findall(r'\(uuid "([0-9a-f-]+)"\)', sch)) lost = uuids_before - uuids_after if lost: sys.exit('REFUSING: %d pre-existing UUIDs vanished' % len(lost)) # ---------- 6. .kicad_pro netclasses ---------- pro = json.load(open(PRO, encoding='utf-8')) ns = pro.setdefault('net_settings', {}) classes = ns.setdefault('classes', []) have = {c.get('name') for c in classes} def clone_base(): for c in classes: if c.get('name')=='PWR_MID': return dict(c) return dict(classes[0]) pro_added=[] for nm, tw, vd in (('RF',0.35,0.4),('PWR_MODEM',1.5,0.6),('PWR_IN',1.5,0.6)): if nm not in have: c = clone_base(); c['name']=nm; c['track_width']=tw if 'via_diameter' in c and vd: c['via_diameter']=max(c.get('via_diameter',vd), vd) classes.append(c); pro_added.append(nm) pats = ns.setdefault('netclass_patterns', []) patset = {(p['netclass'],p['pattern']) for p in pats} WANT = ([('RF',n) for n in ('/RF_GNSS','/RF_ANT_GNSS','/RF_LTE','/RF_ANT_LTE')] + [('PWR_MODEM',n) for n in ('/VBAT_MODEM','/VIN_MODEM','/SW_MODEM')] + [('PWR_IN',n) for n in ('/VIN_FUSED','/VIN_PROT','/SW_PWRIN')]) pro_pats=0 for cls,net in WANT: if (cls,net) not in patset: pats.append({'netclass':cls,'pattern':net}); pro_pats+=1 # ---------- report ---------- print('D_Zener_ANK pin-number repairs (@@@ -> 1/3): %d replacements (expect 4: 2 cache + 2 DzG1 instance)' % zfix) print('new lib symbols in Drogheda-sym: %s' % (added_syms or 'none (already present)')) print('L22 footprint: %s' % l22_fp) print('symbols placed: F2 Q23 D32 D33 D34 L22 U25 PF19 + 15 passives (R124/R125 DNP)') print('labels: %d no_connects: %d' % (nlab,nnc)) print('netclasses added to pro: %s ; patterns added: %d' % (pro_added or 'none', pro_pats)) print('paren: sch=%d dsym=%d' % (bal(sch),bal(dsym))) if not APPLY: print('\nDRY RUN - nothing written. Re-run with --apply.'); sys.exit(0) if bal(sch)!=0 or bal(dsym)!=0: sys.exit('REFUSING: paren imbalance') shutil.copy2(SCH,SCH+'.T9bak'); shutil.copy2(DSYM,DSYM+'.T9bak'); shutil.copy2(PRO,PRO+'.T9bak') open(SCH,'w',encoding='utf-8').write(sch) open(DSYM,'w',encoding='utf-8').write(dsym) json.dump(pro, open(PRO,'w',encoding='utf-8'), indent=2) print('\nAPPLIED. Backups: *.T9bak')