Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJ4eAB6zjQT49RVwpgfKdt
230 lines
12 KiB
Python
230 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""v6 T15 — make the board orderable at JLCPCB: LCSC numbers + fabrication attributes.
|
||
|
||
Why a direct PCB edit: on 25-Sep "Update PCB from Schematic" did not carry the T14 SIM flip
|
||
to the board, so T15 writes the attributes into the .kicad_pcb itself (only `attr` tokens and
|
||
hidden LCSC properties; no geometry, no copper, no placement is touched).
|
||
|
||
Schematic (.kicad_sch):
|
||
1. LCSC field on the 66 populated parts that had none (+ RS1, whose value was a TBD placeholder).
|
||
Every number verified in the local JLC DB on 25-Sep (see LCSC below).
|
||
2. JP1–JP5 (solder jumpers = bare copper) -> in_bom no, in_pos_files no.
|
||
Board (.kicad_pcb):
|
||
3. Same LCSC values on the matching footprints (hidden F.Fab property).
|
||
4. SIM flip that Update PCB didn't carry (T14): J14 + R131–R134 lose `dnp`; U22 + R127–R130 get `dnp`.
|
||
5. U19 (A7670SA LGA) and L22 (SMD inductor) through_hole -> smd.
|
||
6. JP1–JP5 -> exclude_from_pos_files + exclude_from_bom.
|
||
|
||
NOT touched on purpose: F2 (vehicle-input fuse, still TBD — needs Leo's part choice),
|
||
J10/P11/P12 (DNP intent pending Leo), anything geometric.
|
||
|
||
Method: clone-and-transform (LCSC blocks cloned from R131's own), fresh UUIDs, invariant guards,
|
||
dry-run by default, .T15bak backups, idempotent.
|
||
|
||
python3 T15.py hbox-watchdog-v6.kicad_sch hbox-watchdog-v6.kicad_pcb # dry run
|
||
python3 T15.py hbox-watchdog-v6.kicad_sch hbox-watchdog-v6.kicad_pcb --apply # writes .T15bak
|
||
"""
|
||
import re, sys, uuid, shutil
|
||
|
||
LCSC = {
|
||
# 100 nF 0402 X7R 16 V (Basic)
|
||
**{r: 'C1525' for r in 'C121 C122 C123 C126 C129 C134 C137 C138 C139 C140 C144 C158 C159 C143'.split()},
|
||
# 10 uF 0805 X5R 50 V Murata (Basic) — already used 5x on the board; 50 V covers C127 on /VIN_MODEM (24 V)
|
||
**{r: 'C440198' for r in 'C119 C120 C124 C127 C160'.split()},
|
||
'C125': 'C23630', # 2.2 uF 0603 16 V (Basic) on /1V8_MODEM
|
||
'C128': 'C15849', # 1 uF 0603 X5R 50 V (Basic) on /VIN_MODEM (24 V)
|
||
'C133': 'C15849', # 1 uF 0603 50 V on /VBAT_MODEM
|
||
'C135': 'C1562', # 33 pF 0402 C0G 50 V (Basic)
|
||
'C136': 'C32949', # 10 pF 0402 C0G 50 V (Basic)
|
||
'C141': 'C1536', # 3.3 nF 0402 X7R 50 V (Ext) — modem buck compensation
|
||
'C142': 'C1567', # 47 pF 0402 C0G 50 V (Basic) — modem buck compensation
|
||
'C145': 'C1546', # 100 pF 0402 C0G 50 V (Basic)
|
||
'C149': 'C153036', # 2.2 uF 1210 X7R 100 V FH (Ext, 471k stock) on /VIN_PROT
|
||
'C150': 'C153036',
|
||
'C151': 'C116241', # 100 uF 50 V Nichicon UUD 8x10, 105 C / 5000 h (Ext)
|
||
'C152': 'C14663', # 100 nF 0603 50 V (Basic) — U25 bootstrap
|
||
'C157': 'C14663', # 100 nF 0603 on /NTC2
|
||
'C153': 'C5440143', # 47 uF 1210 X7R 16 V (Ext) — same part already on the board
|
||
'C154': 'C5440143',
|
||
'C155': 'C1542', # 6.8 nF 0402 X7R 50 V (Preferred) — U25 compensation
|
||
'C156': 'C1563', # 39 pF 0402 C0G 50 V (Ext) — U25 compensation
|
||
'D20': 'C8678', # SS34 SMA (Basic) — already used 5x
|
||
'D33': 'C80857', # BZX84C15LT1G (Ext) — already used on the board
|
||
'L20': 'C17700167', # Sunlord MWSA0804S-4R7MT 4.7 uH — same family as the other SRU8028-footprint inductors
|
||
'L21': 'C162555', # Murata LQG18HN68NJ00D 68 nH 0603 — GNSS active-antenna bias choke
|
||
'Q20': 'C8545', 'Q21': 'C8545', # 2N7002 CJ (Basic)
|
||
'Q22': 'C20526', # MMBT3904 (Basic) — already used
|
||
'Q23': 'C2624', # IRFR5305TRPBF (Ext) — already used
|
||
'R105': 'C25804', # 10k 1% (Basic)
|
||
'R126': 'C25804',
|
||
'R106': 'C22775', # 100R (Basic)
|
||
'R109': 'C23162', # 4.7k (Basic)
|
||
'R110': 'C23242', # 75k 1% (Basic) — modem buck FB top (0.8 V x (1+75/20) = 3.80 V)
|
||
'R111': 'C4184', # 20k 1% (Basic) — modem buck FB bottom
|
||
'R112': 'C14676', # 1k — same as the other 1k on the board
|
||
**{r: 'C25803' for r in 'R113 R114 R115 R119'.split()}, # 100k 1% (Basic)
|
||
'R116': 'C22803', # 14k 1% (Ext)
|
||
'R117': 'C25811', # 200k 1% (Basic)
|
||
'R118': 'C17168', # 0R 0402 (Basic)
|
||
'R120': 'C165760', # 162k 1% Yageo (Ext) — U25 RT (~620 kHz)
|
||
'R121': 'C23074', # 53.6k 1% (Ext) — U25 FB top (0.8 V x (1+53.6/10.2) = 5.00 V)
|
||
'R122': 'C22772', # 10.2k 1% (Ext) — U25 FB bottom
|
||
'R123': 'C22797', # 13k 1% (Basic) — U25 compensation
|
||
'RS1': 'C105362', # 10 mOhm 1% 1 W 1206 current-sense (Ext) — replaces "TBD-10mR-1206"
|
||
'P8': 'C158012', # JST B2B-XH-A — same as the other 2-pin XH on the board
|
||
'J14': 'C2977286', # JAE SF72S006VBDR2500 — schematic got it in T14b; the board needs it too
|
||
}
|
||
JUMPERS = ['JP1', 'JP2', 'JP3', 'JP4', 'JP5']
|
||
POPULATE = ['J14', 'R131', 'R132', 'R133', 'R134']
|
||
MAKE_DNP = ['U22', 'R127', 'R128', 'R129', 'R130']
|
||
TO_SMD = ['U19', 'L22']
|
||
ATTR_ORDER = ['board_only', 'exclude_from_pos_files', 'exclude_from_bom',
|
||
'allow_missing_courtyard', 'dnp', 'allow_soldermask_bridges']
|
||
|
||
def span(s, i):
|
||
d = 0; instr = False; esc = False; j = i
|
||
while True:
|
||
c = s[j]
|
||
if esc: esc = False
|
||
elif c == '\\': esc = True
|
||
elif c == '"': instr = not instr
|
||
elif not instr:
|
||
if c == '(': d += 1
|
||
elif c == ')':
|
||
d -= 1
|
||
if d == 0: return j + 1
|
||
j += 1
|
||
|
||
def balanced(s):
|
||
d = 0; instr = False; esc = False
|
||
for c in s:
|
||
if esc: esc = False; continue
|
||
if c == '\\': esc = True; continue
|
||
if c == '"': instr = not instr; continue
|
||
if instr: continue
|
||
if c == '(': d += 1
|
||
elif c == ')': d -= 1
|
||
if d < 0: return False
|
||
return d == 0
|
||
|
||
UUID = re.compile(r'\(uuid "([0-9a-f-]{36})"\)')
|
||
|
||
def block(s, ref, head):
|
||
m = re.search(r'\n\t\t\(property "Reference" "%s"\n' % re.escape(ref), s)
|
||
if not m: raise SystemExit(f'{head} {ref} not found')
|
||
a = s.rfind('\n\t(' + head, 0, m.start()) + 1
|
||
return a, span(s, a)
|
||
|
||
def prop_block(blk, name):
|
||
m = re.search(r'\n\t\t\(property "%s" "' % re.escape(name), blk)
|
||
if not m: return None
|
||
a = m.start() + 3
|
||
return a, span(blk, a)
|
||
|
||
def set_lcsc(s, ref, head, value, template_ref, changes, tag):
|
||
"""Set/insert the LCSC property of symbol/footprint `ref`. Returns new s."""
|
||
a, e = block(s, ref, head); blk = s[a:e]
|
||
pb = prop_block(blk, 'LCSC')
|
||
if pb:
|
||
pa, pe = pb; cur = re.match(r'\(property "LCSC" "([^"]*)"', blk[pa:pe]).group(1)
|
||
if cur == value: return s
|
||
if cur and not cur.startswith('TBD'):
|
||
raise SystemExit(f'{tag} {ref}: has real LCSC {cur}, refusing to overwrite with {value}')
|
||
nb = blk[:pa] + blk[pa:pe].replace(f'"LCSC" "{cur}"', f'"LCSC" "{value}"', 1) + blk[pe:]
|
||
changes.append(f'{tag} {ref}: LCSC {cur} -> {value}')
|
||
else:
|
||
if head == 'symbol':
|
||
ta, te = block(s, template_ref, head); tb = s[ta:te]
|
||
pa, pe = prop_block(tb, 'LCSC'); lc = tb[pa:pe]
|
||
x, y = re.search(r'\n\t\t\(at (-?[\d.]+) (-?[\d.]+) ', blk).groups()
|
||
lc = re.sub(r'\(at [^)]*\)', f'(at {x} {y} 0)', lc, count=1)
|
||
else: # footprint: clone this footprint's own hidden Datasheet field (right layer/rotation)
|
||
pa, pe = prop_block(blk, 'Datasheet'); lc = blk[pa:pe]
|
||
if '(hide yes)' not in lc: raise SystemExit(f'{ref}: Datasheet field not hidden, unsafe template')
|
||
lc = re.sub(r'^\(property "[^"]*" "[^"]*"', f'(property "LCSC" "{value}"', lc, count=1)
|
||
lc = UUID.sub(lambda m: f'(uuid "{uuid.uuid4()}")', lc)
|
||
da, de = prop_block(blk, 'Description')
|
||
nb = blk[:de] + '\n\t\t' + lc + blk[de:]
|
||
changes.append(f'{tag} {ref}: + LCSC {value}')
|
||
return s[:a] + nb + s[e:]
|
||
|
||
def sch_flags(s, ref, want, changes):
|
||
a, e = block(s, ref, 'symbol'); blk = s[a:e]
|
||
for k, v in want.items():
|
||
m = re.search(r'\n\t\t\(%s (yes|no)\)' % k, blk)
|
||
if not m: raise SystemExit(f'sch {ref}: no ({k} …)')
|
||
if m.group(1) != v:
|
||
blk = blk[:m.start()] + f'\n\t\t({k} {v})' + blk[m.end():]
|
||
changes.append(f'sch {ref}: {k} {m.group(1)} -> {v}')
|
||
return s[:a] + blk + s[e:]
|
||
|
||
def pcb_attr(s, ref, add=(), remove=(), set_type=None, changes=None):
|
||
a, e = block(s, ref, 'footprint'); blk = s[a:e]
|
||
m = re.search(r'\n\t\t\(attr ([^)]*)\)', blk)
|
||
if not m: # KiCad omits (attr) when the type is "unspecified" and no flags are set
|
||
i = blk.find('\n\t\t(duplicate_pad_numbers_are_jumpers')
|
||
if i < 0: i = re.search(r'\n\t\t\((fp_|pad )', blk).start()
|
||
blk = blk[:i] + '\n\t\t(attr )' + blk[i:]
|
||
m = re.search(r'\n\t\t\(attr ([^)]*)\)', blk)
|
||
toks = m.group(1).split()
|
||
typ = toks[0] if toks and toks[0] in ('smd', 'through_hole') else None
|
||
flags = [t for t in toks if t not in ('smd', 'through_hole')]
|
||
unknown = [t for t in flags if t not in ATTR_ORDER]
|
||
if unknown: raise SystemExit(f'pcb {ref}: unknown attr tokens {unknown}')
|
||
ntyp = set_type or typ
|
||
nflags = (set(flags) | set(add)) - set(remove)
|
||
new = ' '.join(([ntyp] if ntyp else []) + [t for t in ATTR_ORDER if t in nflags])
|
||
if new != m.group(1):
|
||
blk = blk[:m.start()] + f'\n\t\t(attr {new})' + blk[m.end():]
|
||
changes.append(f'pcb {ref}: attr "{m.group(1)}" -> "{new}"')
|
||
return s[:a] + blk + s[e:]
|
||
|
||
def guard(s0, s, what):
|
||
assert balanced(s), f'{what}: output not paren-balanced'
|
||
u0 = set(UUID.findall(s0)); u1 = UUID.findall(s)
|
||
assert u0 <= set(u1), f'{what}: a pre-existing UUID disappeared'
|
||
assert len(u1) == len(set(u1)), f'{what}: duplicate UUID'
|
||
return len(u0), len(u1) - len(u0)
|
||
|
||
def main():
|
||
sch, pcb = sys.argv[1], sys.argv[2]; apply = '--apply' in sys.argv
|
||
S0 = open(sch).read(); P0 = open(pcb).read(); S = S0; P = P0
|
||
assert balanced(S0) and balanced(P0), 'input not paren-balanced'
|
||
ch = []
|
||
n_fp0 = len(re.findall(r'\n\t\(footprint "', P0)); n_sym0 = len(re.findall(r'\n\t\(symbol\s*\(lib_', S0))
|
||
|
||
for ref, val in LCSC.items():
|
||
S = set_lcsc(S, ref, 'symbol', val, 'R131', ch, 'sch')
|
||
P = set_lcsc(P, ref, 'footprint', val, None, ch, 'pcb')
|
||
for ref in JUMPERS:
|
||
S = sch_flags(S, ref, {'in_bom': 'no', 'in_pos_files': 'no'}, ch)
|
||
P = pcb_attr(P, ref, add=('exclude_from_pos_files', 'exclude_from_bom'), changes=ch)
|
||
for ref in POPULATE: P = pcb_attr(P, ref, remove=('dnp',), changes=ch)
|
||
for ref in MAKE_DNP: P = pcb_attr(P, ref, add=('dnp',), changes=ch)
|
||
for ref in TO_SMD: P = pcb_attr(P, ref, set_type='smd', changes=ch)
|
||
|
||
ks, ns = guard(S0, S, 'sch'); kp, np_ = guard(P0, P, 'pcb')
|
||
assert len(re.findall(r'\n\t\(footprint "', P)) == n_fp0, 'footprint count changed'
|
||
assert len(re.findall(r'\n\t\(symbol\s*\(lib_', S)) == n_sym0, 'symbol count changed'
|
||
# geometry invariant: every original board line survives, except attr lines and LCSC values
|
||
from collections import Counter
|
||
keep = lambda t: Counter(l for l in t.split('\n')
|
||
if not re.match(r'\t*\((attr |property "LCSC" )', l))
|
||
lost = keep(P0) - keep(P)
|
||
assert not lost, f'pcb: unexpected changed lines: {list(lost)[:3]}'
|
||
for ref in LCSC: # schematic and board agree
|
||
for t, h in ((S, 'symbol'), (P, 'footprint')):
|
||
a, e = block(t, ref, h); assert f'(property "LCSC" "{LCSC[ref]}"' in t[a:e], (ref, h)
|
||
|
||
print(f'{len(ch)} change(s)'); [print(' ', c) for c in ch]
|
||
print(f'sch UUIDs: {ks} kept, {ns} new | pcb UUIDs: {kp} kept, {np_} new | 0 duplicated; paren-balanced; geometry untouched')
|
||
if not ch: print('nothing to do (already applied)'); return
|
||
if apply:
|
||
shutil.copy2(sch, sch + '.T15bak'); shutil.copy2(pcb, pcb + '.T15bak')
|
||
open(sch, 'w').write(S); open(pcb, 'w').write(P); print('APPLIED (backups .T15bak)')
|
||
else:
|
||
print('DRY RUN — re-run with --apply')
|
||
|
||
if __name__ == '__main__':
|
||
main()
|